fix(tui): preserve prior segment output on Ctrl+C interrupt
interruptTurn only flushed the in-flight streaming chunk (bufRef) to the transcript before calling idle(), which wiped segmentMessages and pendingSegmentTools. Every tool call and commentary line the agent had already emitted in the current turn disappeared the moment the user cancelled, even though that output is exactly what they want to keep when they hit Ctrl+C (quote from the blitz feedback: "everything was fine up until the point where you wanted to push to main"). Append each flushed segment message to the transcript first, then render the in-flight partial with the `*[interrupted]*` marker and its pendingSegmentTools. Sys-level "interrupted" note still fires when there is nothing to preserve.
This commit is contained in:
@@ -240,22 +240,28 @@ export const coreCommands: SlashCommand[] = [
|
||||
return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]')
|
||||
}
|
||||
|
||||
const runner = !target || target === 'auto' ? configureDetectedTerminalKeybindings() : configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf')
|
||||
const runner =
|
||||
!target || target === 'auto'
|
||||
? configureDetectedTerminalKeybindings()
|
||||
: configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf')
|
||||
|
||||
void runner.then(result => {
|
||||
if (ctx.stale()) {
|
||||
return
|
||||
}
|
||||
void runner
|
||||
.then(result => {
|
||||
if (ctx.stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.transcript.sys(result.message)
|
||||
if (result.success && result.requiresRestart) {
|
||||
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect')
|
||||
}
|
||||
}).catch(error => {
|
||||
if (!ctx.stale()) {
|
||||
ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
ctx.transcript.sys(result.message)
|
||||
|
||||
if (result.success && result.requiresRestart) {
|
||||
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect')
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!ctx.stale()) {
|
||||
ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { introMsg, toTranscriptMessages, attachedImageNotice } from '../../../domain/messages.js'
|
||||
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
||||
import type {
|
||||
BackgroundStartResponse,
|
||||
BtwStartResponse,
|
||||
|
||||
@@ -95,14 +95,36 @@ class TurnController {
|
||||
this.interrupted = true
|
||||
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sid }).catch(() => {})
|
||||
|
||||
const segments = this.segmentMessages
|
||||
const partial = this.bufRef.trimStart()
|
||||
const tools = this.pendingSegmentTools
|
||||
|
||||
partial ? appendMessage({ role: 'assistant', text: `${partial}\n\n*[interrupted]*` }) : sys('interrupted')
|
||||
|
||||
// Drain streaming/segment state off the nanostore before writing the
|
||||
// preserved snapshot to the transcript — otherwise each flushed segment
|
||||
// appears in both `turn.streamSegments` and the transcript for one frame.
|
||||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.turnTools = []
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
|
||||
for (const msg of segments) {
|
||||
appendMessage(msg)
|
||||
}
|
||||
|
||||
// Always surface an interruption indicator — if there's an in-flight
|
||||
// `partial` or pending tools, fold them into a single assistant message;
|
||||
// otherwise emit a sys note so the transcript always records that the
|
||||
// turn was cancelled, even when only prior `segments` were preserved.
|
||||
if (partial || tools.length) {
|
||||
appendMessage({
|
||||
role: 'assistant',
|
||||
text: partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*',
|
||||
...(tools.length && { tools })
|
||||
})
|
||||
} else {
|
||||
sys('interrupted')
|
||||
}
|
||||
|
||||
patchUiState({ status: 'interrupted' })
|
||||
this.clearStatusTimer()
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { useStdin } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useStdin } from '@hermes/ink'
|
||||
|
||||
import type { PasteEvent } from '../components/textInput.js'
|
||||
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 { useQueue } from '../hooks/useQueue.js'
|
||||
@@ -16,7 +17,6 @@ import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
|
||||
import { readOsc52Clipboard } from '../lib/osc52.js'
|
||||
import { isRemoteShellSession } from '../lib/terminalSetup.js'
|
||||
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
|
||||
import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js'
|
||||
|
||||
import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
|
||||
import { $isBlocked } from './overlayStore.js'
|
||||
@@ -79,8 +79,8 @@ export function looksLikeDroppedPath(text: string): boolean {
|
||||
trimmed.startsWith("'/") ||
|
||||
trimmed.startsWith('"~') ||
|
||||
trimmed.startsWith("'~") ||
|
||||
(/^[A-Za-z]:[/\\]/.test(trimmed)) ||
|
||||
(/^["'][A-Za-z]:[/\\]/.test(trimmed))
|
||||
/^[A-Za-z]:[/\\]/.test(trimmed) ||
|
||||
/^["'][A-Za-z]:[/\\]/.test(trimmed)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
@@ -90,13 +90,19 @@ export function looksLikeDroppedPath(text: string): boolean {
|
||||
// unnecessary RPC round-trips.
|
||||
if (trimmed.startsWith('/')) {
|
||||
const rest = trimmed.slice(1)
|
||||
|
||||
return rest.includes('/') || rest.includes('.')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function useComposerState({ gw, onClipboardPaste, onImageAttached, submitRef }: UseComposerStateOptions): UseComposerStateResult {
|
||||
export function useComposerState({
|
||||
gw,
|
||||
onClipboardPaste,
|
||||
onImageAttached,
|
||||
submitRef
|
||||
}: UseComposerStateOptions): UseComposerStateResult {
|
||||
const [input, setInput] = useState('')
|
||||
const [inputBuf, setInputBuf] = useState<string[]>([])
|
||||
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
|
||||
@@ -119,7 +125,12 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
|
||||
}, [historyDraftRef, setQueueEdit, setHistoryIdx])
|
||||
|
||||
const handleResolvedPaste = useCallback(
|
||||
async ({ bracketed, cursor, text, value }: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
|
||||
async ({
|
||||
bracketed,
|
||||
cursor,
|
||||
text,
|
||||
value
|
||||
}: Omit<PasteEvent, 'hotkey'>): Promise<null | { cursor: number; value: string }> => {
|
||||
const cleanedText = stripTrailingPasteNewlines(text)
|
||||
|
||||
if (!cleanedText || !/[^\n]/.test(cleanedText)) {
|
||||
@@ -131,6 +142,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
|
||||
}
|
||||
|
||||
const sid = getUiState().sid
|
||||
|
||||
if (sid && looksLikeDroppedPath(cleanedText)) {
|
||||
try {
|
||||
const attached = await gw.request<ImageAttachResponse>('image.attach', {
|
||||
@@ -141,6 +153,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
|
||||
if (attached?.name) {
|
||||
onImageAttached?.(attached)
|
||||
const remainder = attached.remainder?.trim() ?? ''
|
||||
|
||||
if (!remainder) {
|
||||
return { cursor, value }
|
||||
}
|
||||
@@ -198,20 +211,29 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
|
||||
)
|
||||
|
||||
const handleTextPaste = useCallback(
|
||||
({ bracketed, cursor, hotkey, text, value }: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
|
||||
({
|
||||
bracketed,
|
||||
cursor,
|
||||
hotkey,
|
||||
text,
|
||||
value
|
||||
}: PasteEvent): MaybePromise<null | { cursor: number; value: string }> => {
|
||||
if (hotkey) {
|
||||
const preferOsc52 = isRemoteShellSession(process.env)
|
||||
|
||||
const readPreferredText = preferOsc52
|
||||
? readOsc52Clipboard(querier).then(async osc52Text => {
|
||||
if (isUsableClipboardText(osc52Text)) {
|
||||
return osc52Text
|
||||
}
|
||||
|
||||
return readClipboardText()
|
||||
})
|
||||
: readClipboardText().then(async clipText => {
|
||||
if (isUsableClipboardText(clipText)) {
|
||||
return clipText
|
||||
}
|
||||
|
||||
return readOsc52Clipboard(querier)
|
||||
})
|
||||
|
||||
@@ -221,6 +243,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit
|
||||
}
|
||||
|
||||
void onClipboardPaste(false)
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
SudoRespondResponse,
|
||||
VoiceRecordResponse
|
||||
} from '../gatewayTypes.js'
|
||||
|
||||
import { isAction, isMac } from '../lib/platform.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { STARTUP_RESUME_ID } from '../config/env.js'
|
||||
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { fmtCwdBranch } from '../domain/paths.js'
|
||||
import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
@@ -17,6 +16,7 @@ import type {
|
||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user