mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 16:31:56 +08:00
Compare commits
1 Commits
opencode-p
...
feat/stash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f90e5cef |
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
18
ui-tui/src/components/stashIndicator.tsx
Normal file
18
ui-tui/src/components/stashIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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'],
|
||||
|
||||
61
ui-tui/src/hooks/useStash.ts
Normal file
61
ui-tui/src/hooks/useStash.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user