diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index baf637aa25..66310b0dfd 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -134,11 +134,15 @@ export type MaybePromise = Promise | T export interface ComposerActions { clearIn: () => void + cycleStash: (currentText: string) => string dequeue: () => string | undefined enqueue: (text: string) => void + getStashList: () => string[] handleTextPaste: (event: PasteEvent) => MaybePromise openEditor: () => Promise + popStashAt: (index: number) => string pushHistory: (text: string) => void + pushStash: (text: string) => boolean removeQueue: (index: number) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter @@ -155,6 +159,7 @@ export interface ComposerRefs { historyRef: MutableRefObject queueEditRef: MutableRefObject queueRef: MutableRefObject + stashRef: MutableRefObject submitRef: MutableRefObject<(value: string) => void> } @@ -168,6 +173,7 @@ export interface ComposerState { pasteSnips: PasteSnippet[] queueEditIdx: null | number queuedDisplay: string[] + stashCount: number } export interface UseComposerStateOptions { @@ -257,12 +263,17 @@ export interface GatewayEventHandlerContext { export interface SlashHandlerContext { composer: { + cycleStash: (currentText: string) => string enqueue: (text: string) => void + getStashList: () => string[] hasSelection: boolean paste: (quiet?: boolean) => void + popStashAt: (index: number) => string + pushStash: (text: string) => boolean queueRef: MutableRefObject selection: SelectionApi setInput: StateSetter + stashRef: MutableRefObject } gateway: GatewayServices local: { @@ -316,6 +327,7 @@ export interface AppLayoutComposerProps { pagerPageSize: number queueEditIdx: null | number queuedDisplay: string[] + stashCount: number submit: (value: string) => void updateInput: StateSetter } diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index f9b54c34c1..c8c90ef2e9 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -562,5 +562,57 @@ export const coreCommands: SlashCommand[] = [ }) ) } + }, + + { + help: 'list stashed drafts. /stash pop [N] to restore.', + name: 'stash', + run: (arg, ctx) => { + // /stash pop [N] + if (arg && arg.toLowerCase().startsWith('pop')) { + const rest = arg.slice(3).trim() + const n = rest ? parseInt(rest, 10) : 1 + + if (isNaN(n) || n < 1) { + return ctx.transcript.sys('usage: /stash pop [N]') + } + + const list = ctx.composer.getStashList() + + if (n > list.length) { + return ctx.transcript.sys( + `stash empty (only ${list.length} item${list.length === 1 ? '' : 's'})` + ) + } + + const popped = ctx.composer.popStashAt(n - 1) + + if (!popped) { + return ctx.transcript.sys('stash empty') + } + + ctx.composer.setInput(popped) + + return ctx.transcript.sys(`popped draft #${n}`) + } + + // /stash (no arg) — show list + const list = ctx.composer.getStashList() + + if (list.length === 0) { + return ctx.transcript.sys('stash empty') + } + + const lines = list.map((text, i) => { + const preview = text.split('\n')[0]!.slice(0, 40) + const suffix = text.length > 40 || text.includes('\n') ? '…' : '' + + return `[${i + 1}] ${preview}${suffix}` + }) + + ctx.transcript.sys( + `${lines.join(' ')} · ${list.length} draft${list.length === 1 ? '' : 's'}` + ) + } } ] diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 859506db94..2da7c346b5 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -12,6 +12,7 @@ import { LARGE_PASTE } from '../config/limits.js' import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' +import { useStash } from '../hooks/useStash.js' import { useQueue } from '../hooks/useQueue.js' import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js' import { resolveEditor } from '../lib/editor.js' @@ -123,6 +124,8 @@ export function useComposerState({ syncQueue } = useQueue() + const { cycleStash, getStashList, popStashAt, pushStash, stashCount, stashRef } = useStash() + const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw) @@ -299,11 +302,15 @@ export function useComposerState({ const actions = useMemo( () => ({ clearIn, + cycleStash, dequeue, enqueue, + getStashList, handleTextPaste, openEditor, + popStashAt, pushHistory, + pushStash, removeQueue: removeQ, replaceQueue: replaceQ, setCompIdx, @@ -316,10 +323,13 @@ export function useComposerState({ }), [ clearIn, + cycleStash, dequeue, enqueue, + getStashList, handleTextPaste, openEditor, + popStashAt, pushHistory, removeQ, replaceQ, @@ -336,9 +346,10 @@ export function useComposerState({ historyRef, queueEditRef, queueRef, + stashRef, submitRef }), - [historyDraftRef, historyRef, queueEditRef, queueRef, submitRef] + [historyDraftRef, historyRef, queueEditRef, queueRef, stashRef, submitRef] ) const state = useMemo( @@ -351,7 +362,8 @@ export function useComposerState({ inputBuf, pasteSnips, queueEditIdx, - queuedDisplay + queuedDisplay, + stashCount }), [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay] ) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index a74c9e8431..5e7c396e4d 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -439,6 +439,32 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } + if (isAction(key, ch, 's')) { + const full = [...cState.inputBuf, cState.input].join('\n') + + if (full) { + cActions.pushStash(full) + cActions.clearIn() + actions.sys('stashed') + } + + return + } + + if (isAction(key, ch, 'p')) { + const full = [...cState.inputBuf, cState.input].join('\n') + const popped = cActions.cycleStash(full) + + if (popped) { + cActions.clearIn() + cActions.setInput(popped) + } else { + actions.sys('stash empty') + } + + return + } + if (isVoiceToggleKey(key, ch)) { return voiceRecordToggle() } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 9ec18337bb..706b4c055e 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -607,12 +607,17 @@ export function useMainApp(gw: GatewayClient) { () => createSlashHandler({ composer: { + cycleStash: composerActions.cycleStash, enqueue: composerActions.enqueue, + getStashList: composerActions.getStashList, hasSelection, paste, + popStashAt: composerActions.popStashAt, + pushStash: composerActions.pushStash, queueRef: composerRefs.queueRef, selection, - setInput: composerActions.setInput + setInput: composerActions.setInput, + stashRef: composerRefs.stashRef }, gateway, local: { @@ -648,6 +653,8 @@ export function useMainApp(gw: GatewayClient) { selection, send, session, + setSessionStartedAt, + setVoiceEnabled, sys ] ) @@ -771,6 +778,7 @@ export function useMainApp(gw: GatewayClient) { pagerPageSize, queueEditIdx: composerState.queueEditIdx, queuedDisplay: composerState.queuedDisplay, + stashCount: composerState.stashCount, submit, updateInput: composerActions.setInput }), diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 8c2d210ca1..4fcaee2b62 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -25,6 +25,7 @@ import { FpsOverlay } from './fpsOverlay.js' import { HelpHint } from './helpHint.js' import { MessageLine } from './messageLine.js' import { QueuedMessages } from './queuedMessages.js' +import { StashIndicator } from './stashIndicator.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' import { TextInput, type TextInputMouseApi } from './textInput.js' @@ -215,6 +216,8 @@ const ComposerPane = memo(function ComposerPane({ t={ui.theme} /> + + {ui.bgTasks.size > 0 && ( {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running diff --git a/ui-tui/src/components/stashIndicator.tsx b/ui-tui/src/components/stashIndicator.tsx new file mode 100644 index 0000000000..fa7655f338 --- /dev/null +++ b/ui-tui/src/components/stashIndicator.tsx @@ -0,0 +1,18 @@ +import { Text } from '@hermes/ink' + +import { isMac } from '../lib/platform.js' +import type { Theme } from '../theme.js' + +export function StashIndicator({ count, t, textInPrompt }: { count: number; t: Theme; textInPrompt: boolean }) { + if (!count) { + return null + } + + const mod = isMac ? 'Cmd' : 'Ctrl' + + return ( + + {`${count} stashed message${count === 1 ? '' : 's'} ${textInPrompt ? `\u00b7 ${mod}+S to stash ` : ''}\u00b7 ${mod}+P to ${textInPrompt ? 'cycle' : 'pop'}`} + + ) +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 3008f0baf4..ab37f863a0 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -331,18 +331,40 @@ export function TextInput({ }, [cur, display, focus, nativeCursor, placeholder, selected]) useEffect(() => { - if (self.current) { + // If a local edit just propagated and the parent is echoing it back, + // skip the resync (that's the common case — the user typed a char and + // React re-rendered with the matching parent value). We detect this by + // either matching our local buffer OR matching the value we most + // recently scheduled for the parent. + const echoing = + self.current && (value === vRef.current || value === pendingParentValue.current) + + if (echoing) { self.current = false - } else { - setCur(value.length) - setSel(null) - curRef.current = value.length - selRef.current = null - vRef.current = value - lineWidthRef.current = stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value) - undo.current = [] - redo.current = [] + + return } + + // Parent asserted an authoritative value (e.g. clearIn(), setInput + // from a slash command, pop-from-stash). Drop any in-flight local + // change and fully resync — otherwise a pending flushParentChange + // timer would race and restore the old draft on the next render. + if (parentChangeTimer.current) { + clearTimeout(parentChangeTimer.current) + parentChangeTimer.current = null + } + + pendingParentValue.current = null + self.current = false + + setCur(value.length) + setSel(null) + curRef.current = value.length + selRef.current = null + vRef.current = value + lineWidthRef.current = stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value) + undo.current = [] + redo.current = [] }, [value]) useEffect(() => { @@ -744,6 +766,8 @@ export function TextInput({ return } + const mod = isActionMod(k) + // Ctrl chords claimed by useInputHandlers — pass through instead of // letting them fall into readline-style nav or a literal char insert. // Ctrl+B = voice toggle, Ctrl+X = delete queued message while editing. @@ -751,6 +775,7 @@ export function TextInput({ (k.ctrl && inp === 'c') || (k.ctrl && inp === 'b') || (k.ctrl && inp === 'x') || + (mod && (inp === 's' || inp === 'p')) || k.tab || (k.shift && k.tab) || k.pageUp || @@ -774,7 +799,6 @@ export function TextInput({ let c = curRef.current let v = vRef.current - const mod = isActionMod(k) const wordMod = mod || k.meta const actionHome = k.home || (!isMac && mod && inp === 'a') || isMacActionFallback(k, inp, 'a') const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e') diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index b79d08061b..b7e9005c48 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -17,6 +17,8 @@ const copyHotkeys: [string, string][] = isMac export const HOTKEYS: [string, string][] = [ ...copyHotkeys, + [action + '+S', 'stash current draft'], + [action + '+P', 'cycle stashed drafts'], [action + '+D', 'exit'], [action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'], [action + '+L', 'redraw / repaint'], diff --git a/ui-tui/src/hooks/useStash.ts b/ui-tui/src/hooks/useStash.ts new file mode 100644 index 0000000000..667eb82f8f --- /dev/null +++ b/ui-tui/src/hooks/useStash.ts @@ -0,0 +1,61 @@ +import { useCallback, useRef, useState } from 'react' + +export function useStash() { + const stashRef = useRef([]) + const [stashCount, setStashCount] = useState(0) + + const pushStash = useCallback((text: string) => { + if (!text) { + return false + } + + stashRef.current.unshift(text) + setStashCount(stashRef.current.length) + + return true + }, []) + + /** + * Cycle the stash queue: take the front item, and if there is text in the + * composer, push it to the back. Returns the popped front text or ''. + */ + const cycleStash = useCallback((currentText: string) => { + if (stashRef.current.length === 0) { + return '' + } + + const text = stashRef.current.shift()! + + if (currentText) { + stashRef.current.push(currentText) + } + + setStashCount(stashRef.current.length) + + return text + }, []) + + const popStashAt = useCallback((index: number) => { + const arr = stashRef.current + + if (index < 0 || index >= arr.length) { + return '' + } + + const [text] = arr.splice(index, 1) + setStashCount(arr.length) + + return text ?? '' + }, []) + + const peekStash = useCallback(() => stashRef.current[0] ?? '', []) + + const getStashList = useCallback(() => [...stashRef.current], []) + + const clearStash = useCallback(() => { + stashRef.current = [] + setStashCount(0) + }, []) + + return { clearStash, cycleStash, getStashList, peekStash, popStashAt, pushStash, stashCount, stashRef } +}