Compare commits

...

1 Commits

Author SHA1 Message Date
ethernet
b7f90e5cef feat(tui): add ability to stash and pop prompts
with ctrl-s / ctrl-p, letting you interrupt your own prompt to do
something else & bring it back later
2026-04-30 17:26:22 -04:00
10 changed files with 232 additions and 14 deletions

View File

@@ -134,11 +134,15 @@ export type MaybePromise<T> = Promise<T> | T
export interface ComposerActions {
clearIn: () => void
cycleStash: (currentText: string) => string
dequeue: () => string | undefined
enqueue: (text: string) => void
getStashList: () => string[]
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => Promise<void>
popStashAt: (index: number) => string
pushHistory: (text: string) => void
pushStash: (text: string) => boolean
removeQueue: (index: number) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
@@ -155,6 +159,7 @@ export interface ComposerRefs {
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
stashRef: MutableRefObject<string[]>
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<string[]>
selection: SelectionApi
setInput: StateSetter<string>
stashRef: MutableRefObject<string[]>
}
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<string>
}

View File

@@ -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'}`
)
}
}
]

View File

@@ -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]
)

View File

@@ -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()
}

View File

@@ -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
}),

View File

@@ -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}
/>
<StashIndicator count={composer.stashCount} t={ui.theme} textInPrompt={!!composer.input.length || !!composer.inputBuf.length}/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.muted}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running

View File

@@ -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 (
<Text color={t.color.accent} dimColor>
{`${count} stashed message${count === 1 ? '' : 's'} ${textInPrompt ? `\u00b7 ${mod}+S to stash ` : ''}\u00b7 ${mod}+P to ${textInPrompt ? 'cycle' : 'pop'}`}
</Text>
)
}

View File

@@ -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')

View File

@@ -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'],

View File

@@ -0,0 +1,61 @@
import { useCallback, useRef, useState } from 'react'
export function useStash() {
const stashRef = useRef<string[]>([])
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 }
}