Merge pull request #62 from bizzkoot/feat/expand-chat-input

feat: Implement expandable chat input with double-click detection and gradient tooltip
This commit is contained in:
Shantur Rathore
2026-01-14 18:22:56 +00:00
committed by GitHub
4 changed files with 220 additions and 123 deletions

View File

@@ -3,6 +3,6 @@
"version": "0.5.0", "version": "0.5.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.1.10" "@opencode-ai/plugin": "1.1.12"
} }
} }

View File

@@ -0,0 +1,30 @@
import { Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid"
interface ExpandButtonProps {
expandState: () => "normal" | "expanded"
onToggleExpand: (nextState: "normal" | "expanded") => void
}
export default function ExpandButton(props: ExpandButtonProps) {
function handleClick() {
const current = props.expandState()
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
}
return (
<button
type="button"
class="prompt-expand-button"
onClick={handleClick}
aria-label="Toggle chat input height"
>
<Show
when={props.expandState() === "normal"}
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
>
<Maximize2 class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
)
}

View File

@@ -1,6 +1,7 @@
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js" import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack, createMemo } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker" import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button"
import { addToHistory, getHistory } from "../stores/message-history" import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
@@ -46,10 +47,56 @@ export default function PromptInput(props: PromptInputProps) {
const [pasteCount, setPasteCount] = createSignal(0) const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0)
const [mode, setMode] = createSignal<"normal" | "shell">("normal") const [mode, setMode] = createSignal<"normal" | "shell">("normal")
const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal")
const SELECTION_INSERT_MAX_LENGTH = 2000 const SELECTION_INSERT_MAX_LENGTH = 2000
let textareaRef: HTMLTextAreaElement | undefined let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
// Fixed line height for expanded state (15 lines as suggested by dev)
// Fixed line height for web/mobile expanded state (15 lines as suggested)
const EXPANDED_LINES = 15
const LINE_HEIGHT = 24
const FIXED_EXPANDED_HEIGHT = EXPANDED_LINES * LINE_HEIGHT // 360px
const calculateExpandedHeight = () => {
if (!containerRef) {
return 0
}
const root = containerRef.closest(".session-view")
if (!root) {
return 0
}
const rootRect = root.getBoundingClientRect()
// Reserve minimum space for message section
// Use larger reserve for landscape orientation (less vertical space)
const isLandscape = typeof window !== "undefined" && window.innerWidth > window.innerHeight
const MIN_MESSAGE_SPACE = isLandscape ? 150 : 200
const availableForInput = rootRect.height - MIN_MESSAGE_SPACE
return availableForInput
}
const expandedHeight = createMemo(() => {
const state = expandState()
if (state === "normal") return "auto"
// Use fixed height, but cap at available space
// This prevents overflow in landscape or small screens
const availableHeight = calculateExpandedHeight()
const maxHeight = Math.min(FIXED_EXPANDED_HEIGHT, availableHeight * 0.6)
return `${Math.max(maxHeight, 150)}px` // Minimum 150px to be useful
})
const getPlaceholder = () => {
if (mode() === "shell") {
return "Run a shell command (Esc to exit)..."
}
return "Type your message, @file, @agent, or paste images and text..."
}
@@ -702,6 +749,12 @@ export default function PromptInput(props: PromptInputProps) {
void props.onAbortSession() void props.onAbortSession()
} }
function handleExpandToggle(nextState: "normal" | "expanded") {
setExpandState(nextState)
// Keep focus on textarea
textareaRef?.focus()
}
function handleInput(e: Event) { function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement const target = e.target as HTMLTextAreaElement
@@ -765,9 +818,9 @@ export default function PromptInput(props: PromptInputProps) {
item: item:
| { type: "agent"; agent: Agent } | { type: "agent"; agent: Agent }
| { | {
type: "file" type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
} }
| { type: "command"; command: SDKCommand }, | { type: "command"; command: SDKCommand },
) { ) {
if (item.type === "command") { if (item.type === "command") {
@@ -1161,94 +1214,101 @@ export default function PromptInput(props: PromptInputProps) {
</For> </For>
</div> </div>
</Show> </Show>
<div class="prompt-input-field-container"> <div
class="prompt-input-field-container"
style={{
"height": expandedHeight(),
"transition": "height 0.25s ease",
}}
>
<div class="prompt-input-field"> <div class="prompt-input-field">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`} class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={ placeholder={getPlaceholder()}
mode() === "shell" value={prompt()}
? "Run a shell command (Esc to exit)..." onInput={handleInput}
: "Type your message, @file, @agent, or paste images and text..." onKeyDown={handleKeyDown}
} onPaste={handlePaste}
value={prompt()} onFocus={() => setIsFocused(true)}
onInput={handleInput} onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown} disabled={props.disabled}
onPaste={handlePaste} rows={4}
onFocus={() => setIsFocused(true)} style={{
onBlur={() => setIsFocused(false)} "padding-top": attachments().length > 0 ? "8px" : "0",
disabled={props.disabled} "overflow-y": expandState() !== "normal" ? "auto" : "visible",
rows={4} }}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}} spellcheck={false}
spellcheck={false} autocorrect="off"
autocorrect="off" autoCapitalize="off"
autoCapitalize="off" autocomplete="off"
autocomplete="off" />
/> <div class="prompt-nav-buttons">
<Show when={hasHistory()}> <ExpandButton
<div class="prompt-history-top"> expandState={expandState}
<button onToggleExpand={handleExpandToggle}
type="button" />
class="prompt-history-button" <Show when={hasHistory()}>
onClick={() => selectPreviousHistory(true)} <button
disabled={!canHistoryGoPrevious()} type="button"
aria-label="Previous prompt" class="prompt-history-button"
> onClick={() => selectPreviousHistory(true)}
<ArrowBigUp class="h-5 w-5" aria-hidden="true" /> disabled={!canHistoryGoPrevious()}
</button> aria-label="Previous prompt"
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()}
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div> </div>
<div class="prompt-history-bottom"> <Show when={shouldShowOverlay()}>
<button <div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
type="button" <Show
class="prompt-history-button" when={props.escapeInDebounce}
onClick={() => selectNextHistory(true)} fallback={
disabled={!canHistoryGoNext()} <>
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text"> <span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text} <Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span> </span>
</Show> <Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}> <Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span> <span class="prompt-overlay-shell-active">Shell mode active</span>
</Show> </Show>
</> </>
} </Show>
> </div>
<> </Show>
<span class="prompt-overlay-text prompt-overlay-warning"> </div>
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
</Show>
</div>
</Show>
</div> </div>
</div> </div>
</div>
<div class="prompt-input-actions"> <div class="prompt-input-actions">
<button <button

View File

@@ -13,7 +13,7 @@
} }
.prompt-input-actions { .prompt-input-actions {
@apply flex flex-col items-center justify-between; @apply flex flex-col items-center;
align-self: stretch; align-self: stretch;
height: 100%; height: 100%;
padding: 0.5rem 0.25rem; padding: 0.5rem 0.25rem;
@@ -27,6 +27,7 @@
flex: 1 1 auto; flex: 1 1 auto;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
transition: height 0.25s ease;
} }
.prompt-input-field { .prompt-input-field {
@@ -36,18 +37,18 @@
height: 100%; height: 100%;
} }
.prompt-input { .prompt-input {
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors; @apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
font-family: inherit; font-family: inherit;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
border-color: var(--border-base); border-color: var(--border-base);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
border-radius: 0; border-radius: 0;
padding-bottom: 0; padding-bottom: 0;
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
} }
.prompt-input-overlay { .prompt-input-overlay {
@@ -69,37 +70,42 @@
color: var(--text-primary); color: var(--text-primary);
} }
.prompt-history-top, /* Navigation buttons container (expand, prev, next) */
.prompt-history-bottom { .prompt-nav-buttons {
position: absolute; position: absolute;
right: 0.35rem; top: 0.25rem;
right: 0.25rem;
bottom: 0.25rem;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; justify-content: flex-start;
gap: 0.125rem;
z-index: 2; z-index: 2;
} }
.prompt-history-top { .prompt-expand-button,
top: 0.3rem;
}
.prompt-history-bottom {
bottom: 0.6rem;
}
.prompt-history-button { .prompt-history-button {
@apply w-9 h-9 flex items-center justify-center rounded-md; @apply w-7 h-7 flex items-center justify-center rounded-md;
color: var(--text-muted); color: var(--text-muted);
background-color: rgba(15, 23, 42, 0.04); background-color: rgba(15, 23, 42, 0.04);
transition: background-color 0.15s ease, color 0.15s ease; transition: background-color 0.15s ease, color 0.15s ease;
padding: 0; padding: 0;
flex-shrink: 0;
} }
.prompt-expand-button:hover:not(:disabled),
.prompt-history-button:hover:not(:disabled) { .prompt-history-button:hover:not(:disabled) {
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
color: var(--text-primary); color: var(--text-primary);
} }
.prompt-expand-button:active:not(:disabled) {
background-color: var(--accent-primary);
color: var(--text-inverted);
transform: scale(0.95);
}
.prompt-expand-button:disabled,
.prompt-history-button:disabled { .prompt-history-button:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
@@ -176,6 +182,7 @@
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0; @apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
background-color: var(--accent-primary); background-color: var(--accent-primary);
color: var(--text-inverted); color: var(--text-inverted);
margin-top: auto;
} }
.send-button.shell-mode { .send-button.shell-mode {