Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/terminal-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The chat command supports these slash commands:

Quote file paths with spaces, for example `/import claude "exports/chat.json"`. Prefix a slash command with another slash to send it as a message, such as `//help`.

Ctrl-C aborts an active turn through the runner's abort signal. At the prompt it cancels the current input. Ctrl-D or end-of-file exits; pending messages are saved before exit. The session archive is the source of saved conversation history. Turn failures and save errors are shown in the terminal. A revision conflict keeps the turn pending and requires an explicit `/save` retry. An input backlog above the safety limit is discarded with an error, and chat exits so it cannot interpret a partial paste as complete input.
Ctrl-C aborts an active turn through the runner's abort signal; a second Ctrl-C while the turn is stopping force quits without saving. At the prompt it cancels the current input. Ctrl-D or end-of-file exits; pending messages are saved before exit. The session archive is the source of saved conversation history. Turn failures and save errors are shown in the terminal. A revision conflict keeps the turn pending and requires an explicit `/save` retry. An input backlog above the safety limit is discarded with an error, and chat exits so it cannot interpret a partial paste as complete input.

Each turn uses the existing headless runner's current modes, permissions, budgets, evidence configuration, and emergency controls. `SIGTERM` stops the active turn and saves captured messages before chat exits. The archive keeps messages captured before context compaction, but it is an editable conversation record, not an authenticated evidence journal. Historical system and developer instructions and opaque content are excluded from continuation context; older context may be shortened with a displayed warning.

Expand Down
43 changes: 41 additions & 2 deletions src/terminal/__tests__/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ function streams(text: string): { input: PassThrough; output: PassThrough; read:
}

function dependencies(store: MemoryStore, runTurn: ChatTurnRunner, input: PassThrough, output: PassThrough, overrides: Partial<ChatCliDependencies> = {}): ChatCliDependencies {
return { createStore: () => store, runTurn, input, output, errorOutput: output, terminal: false, cwd: workspaceRoot, env: { NO_COLOR: "1" }, ...overrides }
const unused: (code: number) => never = () => {
throw new Error("forceExit should be stubbed")
}
return { createStore: () => store, runTurn, input, output,
errorOutput: output, terminal: false, cwd: workspaceRoot,
env: { NO_COLOR: "1" }, forceExit: unused, ...overrides }
}

async function testArgsAndSlashParsing(): Promise<void> {
Expand Down Expand Up @@ -455,6 +460,39 @@ async function testReadlineCtrlCInPseudoTerminal(): Promise<void> {
assert.match(result.output, /PTY_RESULT:interrupt/)
}

function ttyStreams(): { input: PassThrough; output: PassThrough } {
const input = new PassThrough() as PassThrough & { isTTY: boolean }
const output = new PassThrough() as PassThrough & { isTTY: boolean }
input.isTTY = true
output.isTTY = true
return { input, output }
}

async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 50))
}

async function testSecondInterruptForceQuitsHungTurn(): Promise<void> {
const { input, output } = ttyStreams()
let turnStarted = false; const exits: number[] = []
const hung: ChatTurnRunner = async () => {
turnStarted = true; return await new Promise<never>(() => {})
}
const forceExit = ((code: number): void => {
exits.push(code); input.end()
}) as (code: number) => never
const deps = dependencies(new MemoryStore(), hung, input, output,
{ terminal: true, env: {}, forceExit })
chatMain([], deps)
input.write("work\n"); while (!turnStarted) await settle()
input.write(String.fromCharCode(3))
await settle()
assert.deepEqual(exits, [])
input.write(String.fromCharCode(3))
await settle()
assert.deepEqual(exits, [130])
}

async function main(): Promise<void> {
await testArgsAndSlashParsing()
await testTurnsPersistAndResume()
Expand All @@ -467,7 +505,8 @@ async function main(): Promise<void> {
await testSaveFailureCanBeRetried()
await testInputAndRendererBounds()
await testReadlineCtrlCInPseudoTerminal()
console.log("All 11 terminal chat tests passed")
await testSecondInterruptForceQuitsHungTurn()
console.log("All 12 terminal chat tests passed")
}

main().catch((error) => { console.error(error); process.exitCode = 1 })
15 changes: 13 additions & 2 deletions src/terminal/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface ChatCliDependencies {
validateMode?: (mode: string) => boolean | Promise<boolean>
skills?: ChatSkillService
imports?: ChatImportService
forceExit: (code: number) => never
}

export interface ChatCliOptions { workspaceRoot: string; resumeId?: string; model?: string; mode?: string; help: boolean }
Expand Down Expand Up @@ -101,6 +102,7 @@ export async function chatMain(argv: string[], deps: ChatCliDependencies): Promi
let model = options.model ?? deps.initialModel
let mode = options.mode ?? deps.initialMode ?? DEFAULT_MODE
let activeTurn: AbortController | undefined
let abortRequested = false
let exitRequested = false
let exitCode = 0
let pendingMessages: SessionMessage[] = []
Expand Down Expand Up @@ -220,6 +222,7 @@ export async function chatMain(argv: string[], deps: ChatCliDependencies): Promi
} finally {
deps.signal?.removeEventListener("abort", externalAbort)
activeTurn = undefined
abortRequested = false
}
const messages = [...result.messages]
if (!messages.some((message) => message.role === "user" && message.content === prompt)) {
Expand Down Expand Up @@ -344,8 +347,15 @@ export async function chatMain(argv: string[], deps: ChatCliDependencies): Promi

input.setInterruptHandler(() => {
if (activeTurn) {
if (abortRequested) {
renderer.writeError("Turn is not responding; force quitting.")
deps.forceExit(130)
return true
}
abortRequested = true
activeTurn.abort(new Error("interrupted by user"))
renderer.writeNotice("Interrupt requested; waiting for the active turn to stop.")
renderer.writeNotice("Interrupt requested; waiting for the turn to stop.")
renderer.writeNotice("Press Ctrl-C again to force quit without saving.")
return true
}
return false
Expand Down Expand Up @@ -424,7 +434,8 @@ export function chatHelp(): string {
"Enter a message to start a turn. Use \"\"\" on its own line to enter or end a multiline message.",
"/help /exit /new /fork /resume <id> /sessions /save",
"/model [name] /mode [name] /skills /skill <id> /import <provider> <file>",
"Prefix a slash command with another / to send it as a message. Ctrl-C cancels an active turn; Ctrl-D exits.",
"Prefix a slash command with another / to send it as a message.",
"Ctrl-C cancels an active turn, twice force quits; Ctrl-D exits.",
].join("\n")
}

Expand Down
12 changes: 12 additions & 0 deletions src/terminal/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import { chatMain, chatUsage, parseChatArgs } from "./chat.js"
import { createChatTurnRunner } from "./bridge.js"
import { sanitizeTerminalText } from "./renderer.js"

export function defaultForceExit(code: number): never {
process.exit(code)
}

function chatForceExit(
runtime: TerminalRuntimeOptions,
): (code: number) => never {
return runtime.forceExit ?? defaultForceExit
}

export interface TerminalRuntimeOptions {
llmClient?: LlmClient
skillRegistry?: SkillRegistry
Expand All @@ -19,6 +29,7 @@ export interface TerminalRuntimeOptions {
errorOutput?: Writable
signal?: AbortSignal
extraArgs?: string[]
forceExit?: (code: number) => never
}

export async function terminalMain(argv: string[], runtime: TerminalRuntimeOptions = {}): Promise<number> {
Expand Down Expand Up @@ -53,6 +64,7 @@ export async function terminalMain(argv: string[], runtime: TerminalRuntimeOptio
return await chatMain(argv, {
runTurn, createStore: (workspace) => new SessionStore(workspace),
input: runtime.input, output, errorOutput, signal,
forceExit: chatForceExit(runtime),
validateMode: async (mode) => Boolean(getModeBySlug(mode, await loadCustomModes(options.workspaceRoot)) ?? modes.find((entry) => entry.slug === mode)),
skills: { list: async () => frozenRegistry.skills, activate: async (name) => frozenRegistry.activate(name) },
imports: { importFile: async (provider, file, workspace) => {
Expand Down
Loading