praxis-agent 0.45.4 → 0.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/application/session-service.d.ts +4 -0
- package/dist/application/session-service.js +83 -0
- package/dist/cli/interactive.d.ts +3 -0
- package/dist/cli/interactive.js +239 -37
- package/dist/cli/tui/composer-prompt-history.d.ts +17 -0
- package/dist/cli/tui/composer-prompt-history.js +63 -0
- package/dist/cli/tui/quiet-frame.d.ts +6 -1
- package/dist/cli/tui/quiet-frame.js +4 -0
- package/dist/cli/tui/quiet-screen-projector.d.ts +1 -0
- package/dist/cli/tui/quiet-screen-projector.js +3 -0
- package/dist/cli/tui/tui-interaction-router.js +3 -2
- package/dist/core/active-turn-input.d.ts +63 -0
- package/dist/core/active-turn-input.js +53 -0
- package/dist/core/runtime.d.ts +12 -0
- package/dist/core/runtime.js +66 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,7 +106,10 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
|
|
|
106
106
|
terminal-native background, and a minimal composer/status row. Interactive
|
|
107
107
|
surfaces share the same presentation across terminals, with English
|
|
108
108
|
permission/configuration choices and a taught `❯` / Up/Down / Enter / Esc
|
|
109
|
-
interaction grammar,
|
|
109
|
+
interaction grammar. While a regular turn is active, the composer remains
|
|
110
|
+
editable: Enter steers at the next safe continuation boundary, Tab or
|
|
111
|
+
Alt+Enter queues a sequential follow-up turn, and pending input stays visible
|
|
112
|
+
and can be withdrawn with Up. The TUI also includes a
|
|
110
113
|
shared-command
|
|
111
114
|
slash palette, tabbed help and shortcut surfaces, searchable resume picker,
|
|
112
115
|
restored active-branch conversation history, streaming and expandable
|
|
@@ -9,6 +9,7 @@ import { type ClaudeInterruptionClassification } from '../native/interruption.js
|
|
|
9
9
|
import { type ModelDocument, type ModelImage, type ModelToolCall, type ModelProvider, type ModelUsage, type PermissionApproval, type PermissionDecision, type PermissionResolver, type PermissionUpdate, type RuntimeEventSink, type ToolRegistry } from '../core/runtime.js';
|
|
10
10
|
import { type BackgroundTaskSnapshot } from './background-task-runtime.js';
|
|
11
11
|
import type { ModelPricingRegistry } from '../core/usage.js';
|
|
12
|
+
import { type ActiveTurnInputCommandResult } from '../core/active-turn-input.js';
|
|
12
13
|
import type { Compactor } from '../core/compaction.js';
|
|
13
14
|
import { ContextBudget } from '../core/context-budget.js';
|
|
14
15
|
import { type ContextAssembler } from '../core/context.js';
|
|
@@ -227,6 +228,7 @@ export declare class ClaudeSessionService {
|
|
|
227
228
|
private readonly hookLifecycle;
|
|
228
229
|
private readonly leadOperations;
|
|
229
230
|
private readonly fileChangeWatcher;
|
|
231
|
+
private readonly activeTurnInputs;
|
|
230
232
|
private runtimeCwd;
|
|
231
233
|
constructor(options: ClaudeSessionServiceOptions);
|
|
232
234
|
nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
|
|
@@ -270,6 +272,8 @@ export declare class ClaudeSessionService {
|
|
|
270
272
|
run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[]): Promise<SessionRunResult>;
|
|
271
273
|
runShell(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
|
|
272
274
|
resume(sessionId: string, prompt: string, signal?: AbortSignal, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[], resumeSessionAt?: string): Promise<SessionRunResult>;
|
|
275
|
+
steer(sessionId: string, content: string): ActiveTurnInputCommandResult;
|
|
276
|
+
withdrawSteering(sessionId: string, id: string): ActiveTurnInputCommandResult;
|
|
273
277
|
resumeShell(sessionId: string, command: string, signal?: AbortSignal, name?: string, resumeSessionAt?: string): Promise<SessionRunResult>;
|
|
274
278
|
answerSideQuestion(sessionId: string | undefined, question: string, signal?: AbortSignal, onDelta?: (delta: string) => void, permissionMode?: ClaudePermissionMode): Promise<SideQuestionResult>;
|
|
275
279
|
forkSideQuestion(sessionId: string, question: string, signal?: AbortSignal): Promise<SideQuestionForkResult>;
|
|
@@ -21,6 +21,7 @@ import { BackgroundTaskRuntime, } from './background-task-runtime.js';
|
|
|
21
21
|
import { backgroundAgentNotificationMarkers, } from './background-agent-manager.js';
|
|
22
22
|
import { usageCostUsd } from '../core/usage.js';
|
|
23
23
|
import { isSessionId } from '../core/session.js';
|
|
24
|
+
import { ActiveTurnInputMailbox, } from '../core/active-turn-input.js';
|
|
24
25
|
import { ContextBudget, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
|
|
25
26
|
import { ContextEngine } from './context-engine.js';
|
|
26
27
|
import { TurnMemoryCoordinator } from './turn-memory-coordinator.js';
|
|
@@ -682,6 +683,7 @@ export class ClaudeSessionService {
|
|
|
682
683
|
hookLifecycle;
|
|
683
684
|
leadOperations;
|
|
684
685
|
fileChangeWatcher;
|
|
686
|
+
activeTurnInputs = new Map();
|
|
685
687
|
runtimeCwd;
|
|
686
688
|
constructor(options) {
|
|
687
689
|
const dataPlane = options.dataPlane ?? 'native';
|
|
@@ -887,6 +889,18 @@ export class ClaudeSessionService {
|
|
|
887
889
|
}
|
|
888
890
|
async close() {
|
|
889
891
|
this.closing = true;
|
|
892
|
+
for (const { mailbox } of this.activeTurnInputs.values()) {
|
|
893
|
+
if (!mailbox)
|
|
894
|
+
continue;
|
|
895
|
+
for (const item of mailbox.close()) {
|
|
896
|
+
this.options.eventSink?.({
|
|
897
|
+
type: 'user-input-rejected',
|
|
898
|
+
id: item.id,
|
|
899
|
+
content: item.content,
|
|
900
|
+
reason: 'closed',
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
890
904
|
await this.fileChangeWatcher?.close(5_000);
|
|
891
905
|
await this.hookLifecycle.close();
|
|
892
906
|
await this.drainDetachedHookRuns(5_000);
|
|
@@ -1299,6 +1313,30 @@ export class ClaudeSessionService {
|
|
|
1299
1313
|
...(signal ? { signal } : {}),
|
|
1300
1314
|
});
|
|
1301
1315
|
}
|
|
1316
|
+
steer(sessionId, content) {
|
|
1317
|
+
const active = this.activeTurnInputs.get(sessionId);
|
|
1318
|
+
if (!active)
|
|
1319
|
+
return { kind: 'no-active-turn' };
|
|
1320
|
+
const mailbox = active.mailbox;
|
|
1321
|
+
if (!mailbox)
|
|
1322
|
+
return { kind: 'not-steerable' };
|
|
1323
|
+
const result = mailbox.enqueue(content);
|
|
1324
|
+
if (result.kind === 'accepted')
|
|
1325
|
+
return result;
|
|
1326
|
+
if (result.kind === 'empty')
|
|
1327
|
+
return result;
|
|
1328
|
+
return { kind: 'turn-completing' };
|
|
1329
|
+
}
|
|
1330
|
+
withdrawSteering(sessionId, id) {
|
|
1331
|
+
const active = this.activeTurnInputs.get(sessionId);
|
|
1332
|
+
if (!active)
|
|
1333
|
+
return { kind: 'no-active-turn' };
|
|
1334
|
+
const mailbox = active.mailbox;
|
|
1335
|
+
if (!mailbox)
|
|
1336
|
+
return { kind: 'not-steerable' };
|
|
1337
|
+
const result = mailbox.withdraw(id);
|
|
1338
|
+
return result.kind === 'withdrawn' ? result : { kind: 'not-pending' };
|
|
1339
|
+
}
|
|
1302
1340
|
async resumeShell(sessionId, command, signal, name, resumeSessionAt) {
|
|
1303
1341
|
this.worktreeManager?.bindSession(sessionId);
|
|
1304
1342
|
return this.executeTurn({
|
|
@@ -2449,6 +2487,8 @@ export class ClaudeSessionService {
|
|
|
2449
2487
|
const shellCommand = submission.kind === 'shell' ? submission.command : undefined;
|
|
2450
2488
|
const skipUserPrompt = submission.kind === 'retry';
|
|
2451
2489
|
const controller = new TurnTerminalController(this.options.eventSink ?? (() => undefined));
|
|
2490
|
+
let activeTurnInput;
|
|
2491
|
+
let activeTurnRecord;
|
|
2452
2492
|
try {
|
|
2453
2493
|
this.assertTurnWritable();
|
|
2454
2494
|
if (prompt.length === 0 && images.length === 0 && documents.length === 0)
|
|
@@ -2459,6 +2499,16 @@ export class ClaudeSessionService {
|
|
|
2459
2499
|
if (shellCommand !== undefined && shellCommand.trim().length === 0) {
|
|
2460
2500
|
throw new Error('Shell command must not be empty');
|
|
2461
2501
|
}
|
|
2502
|
+
if (this.activeTurnInputs.has(sessionId)) {
|
|
2503
|
+
throw new Error(`conflict: locked (session ${sessionId} already has an active turn)`);
|
|
2504
|
+
}
|
|
2505
|
+
activeTurnRecord =
|
|
2506
|
+
shellCommand === undefined
|
|
2507
|
+
? {
|
|
2508
|
+
mailbox: (activeTurnInput = new ActiveTurnInputMailbox(randomUUID)),
|
|
2509
|
+
}
|
|
2510
|
+
: {};
|
|
2511
|
+
this.activeTurnInputs.set(sessionId, activeTurnRecord);
|
|
2462
2512
|
await this.activateSessionCostTracker(sessionId);
|
|
2463
2513
|
await this.ensureFileResources(sessionId, signal);
|
|
2464
2514
|
this.worktreeManager?.bindSession(sessionId);
|
|
@@ -3213,6 +3263,24 @@ export class ClaudeSessionService {
|
|
|
3213
3263
|
}
|
|
3214
3264
|
await durableFollowUps.followUpUserMessagesCompleted(messages);
|
|
3215
3265
|
},
|
|
3266
|
+
userInputDelivered: async (item) => {
|
|
3267
|
+
if (nativeLease) {
|
|
3268
|
+
await nativeLease.appendMessages({
|
|
3269
|
+
messages: [{ role: 'user', content: item.content }],
|
|
3270
|
+
});
|
|
3271
|
+
currentTurnUserMessages?.push(item.content);
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
const [steeringEntry] = translateProviderEvents([{ type: 'user-text-block', text: item.content }], this.translationContext(sessionId, snapshot));
|
|
3275
|
+
if (!steeringEntry)
|
|
3276
|
+
throw new Error('Could not translate steering message');
|
|
3277
|
+
const steeringTail = await this.append(lease, snapshot.tail, steeringEntry);
|
|
3278
|
+
snapshot = {
|
|
3279
|
+
entries: [...snapshot.entries, steeringEntry],
|
|
3280
|
+
tail: steeringTail,
|
|
3281
|
+
};
|
|
3282
|
+
currentTurnUserMessages?.push(item.content);
|
|
3283
|
+
},
|
|
3216
3284
|
};
|
|
3217
3285
|
let turnCompleted = false;
|
|
3218
3286
|
try {
|
|
@@ -3974,6 +4042,7 @@ export class ClaudeSessionService {
|
|
|
3974
4042
|
cwd: this.activeCwd(),
|
|
3975
4043
|
toolResultDirectory,
|
|
3976
4044
|
observer,
|
|
4045
|
+
...(activeTurnInput ? { steering: activeTurnInput } : {}),
|
|
3977
4046
|
...(this.options.effort ? { effort: this.options.effort } : {}),
|
|
3978
4047
|
...(this.options.maxModelTurns !== undefined
|
|
3979
4048
|
? { maxModelTurns: this.options.maxModelTurns }
|
|
@@ -4373,6 +4442,20 @@ export class ClaudeSessionService {
|
|
|
4373
4442
|
controller.fail(error, signal);
|
|
4374
4443
|
throw error;
|
|
4375
4444
|
}
|
|
4445
|
+
finally {
|
|
4446
|
+
if (activeTurnInput !== undefined) {
|
|
4447
|
+
for (const item of activeTurnInput.close()) {
|
|
4448
|
+
this.options.eventSink?.({
|
|
4449
|
+
type: 'user-input-rejected',
|
|
4450
|
+
id: item.id,
|
|
4451
|
+
content: item.content,
|
|
4452
|
+
reason: signal?.aborted ? 'cancelled' : 'failed',
|
|
4453
|
+
});
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
if (this.activeTurnInputs.get(sessionId) === activeTurnRecord)
|
|
4457
|
+
this.activeTurnInputs.delete(sessionId);
|
|
4458
|
+
}
|
|
4376
4459
|
}
|
|
4377
4460
|
async ensureFileResources(sessionId, signal) {
|
|
4378
4461
|
const resources = this.options.fileResources ?? [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ForkResult, ManualCompactResult, ManualCompactSelection, RewindPoint, SessionForkCheckpoint, SessionRunResult, SessionSummary, SideQuestionForkResult, SideQuestionResult } from '../application/session-service.js';
|
|
2
2
|
import type { ClaudeSessionCostSnapshot } from '../application/session-cost-tracker.js';
|
|
3
3
|
import type { ModelImage, ModelToolCall, PermissionApproval, PermissionDecision, RuntimeEventSink } from '../core/runtime.js';
|
|
4
|
+
import type { ActiveTurnInputCommandResult } from '../core/active-turn-input.js';
|
|
4
5
|
import { type DataPlane } from '../persistence/data-plane.js';
|
|
5
6
|
import type { CliElicitationRequest, CliElicitationResult, CliRuntimeInfo } from './protocol.js';
|
|
6
7
|
import type { ClaudeInteractiveToolCallbacks } from '../tools/claude-interactive-tools.js';
|
|
@@ -34,6 +35,8 @@ import type { BackgroundTaskSnapshot } from '../application/background-task-runt
|
|
|
34
35
|
interface InteractiveSessionCommands {
|
|
35
36
|
run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[]): Promise<SessionRunResult>;
|
|
36
37
|
resume(sessionId: string, prompt: string, signal?: AbortSignal, name?: string, images?: readonly ModelImage[]): Promise<SessionRunResult>;
|
|
38
|
+
steer?(sessionId: string, content: string): ActiveTurnInputCommandResult;
|
|
39
|
+
withdrawSteering?(sessionId: string, id: string): ActiveTurnInputCommandResult;
|
|
37
40
|
runShell?(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
|
|
38
41
|
resumeShell?(sessionId: string, command: string, signal?: AbortSignal): Promise<SessionRunResult>;
|
|
39
42
|
fork(sessionId: string, targetSessionId?: string, resumeSessionAt?: string): Promise<ForkResult>;
|
package/dist/cli/interactive.js
CHANGED
|
@@ -44,6 +44,7 @@ import { projectTuiCommandPalette, tuiCommandPaletteCommandId, } from './tui/com
|
|
|
44
44
|
import { runDoctor, } from '../maintenance/doctor.js';
|
|
45
45
|
import { canonicalClaudeCostModelName, formatCostSummary, } from './tui/cost-summary.js';
|
|
46
46
|
import { createComposerEditor, deleteComposerBackward, deleteComposerForward, insertComposerText, moveComposerCursor, } from './tui/composer-editor.js';
|
|
47
|
+
import { createComposerPromptHistory, navigateComposerPromptHistory, recordComposerPrompt, resetComposerPromptHistoryNavigation, seedComposerPromptHistory, } from './tui/composer-prompt-history.js';
|
|
47
48
|
import { routeComposerKey, } from './tui/composer-key-router.js';
|
|
48
49
|
import { routeTuiInteraction, } from './tui/tui-interaction-router.js';
|
|
49
50
|
import { currentTuiInteractionLayer, projectTuiFocusStack, } from './tui/tui-focus-stack.js';
|
|
@@ -239,6 +240,9 @@ const HIDDEN_TUI_SLASH_COMMANDS = new Set([
|
|
|
239
240
|
'update',
|
|
240
241
|
'usage',
|
|
241
242
|
]);
|
|
243
|
+
function transcriptPrompts(items) {
|
|
244
|
+
return items.flatMap((item) => (item.kind === 'user' ? [item.text] : []));
|
|
245
|
+
}
|
|
242
246
|
/** Advances the React-owned transcript plus its exact local mutation fact. */
|
|
243
247
|
export function advanceInteractiveHistoryState(current, items, changedFrom) {
|
|
244
248
|
if (!Number.isInteger(changedFrom) ||
|
|
@@ -372,9 +376,16 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
372
376
|
const [pendingFork, setPendingFork] = useState(resume?.forkSession === true);
|
|
373
377
|
const inputRef = useRef('');
|
|
374
378
|
const inputCursorRef = useRef(0);
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
const
|
|
379
|
+
const promptHistoryRef = useRef(seedComposerPromptHistory(transcriptPrompts(initialHistory)));
|
|
380
|
+
const [pendingInputs, setPendingInputs] = useState([]);
|
|
381
|
+
const pendingInputsRef = useRef(pendingInputs);
|
|
382
|
+
pendingInputsRef.current = pendingInputs;
|
|
383
|
+
const followUpQueueRef = useRef([]);
|
|
384
|
+
const updatePendingInputs = (update) => {
|
|
385
|
+
const next = typeof update === 'function' ? update(pendingInputsRef.current) : update;
|
|
386
|
+
pendingInputsRef.current = next;
|
|
387
|
+
setPendingInputs(next);
|
|
388
|
+
};
|
|
378
389
|
const undoStackRef = useRef([]);
|
|
379
390
|
const composerImagesRef = useRef(new Map());
|
|
380
391
|
const nextImageIdRef = useRef(1);
|
|
@@ -1457,6 +1468,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
1457
1468
|
fileSelectionRef.current = 0;
|
|
1458
1469
|
};
|
|
1459
1470
|
const updateComposerEditor = (editor, recordUndo = true) => {
|
|
1471
|
+
promptHistoryRef.current = resetComposerPromptHistoryNavigation(promptHistoryRef.current);
|
|
1460
1472
|
if (recordUndo && editor.text !== inputRef.current) {
|
|
1461
1473
|
undoStackRef.current = [
|
|
1462
1474
|
...undoStackRef.current,
|
|
@@ -1465,7 +1477,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
1465
1477
|
}
|
|
1466
1478
|
updateComposerInput(editor.text, editor.cursor);
|
|
1467
1479
|
};
|
|
1468
|
-
const clearComposerInput = () =>
|
|
1480
|
+
const clearComposerInput = () => {
|
|
1481
|
+
promptHistoryRef.current = resetComposerPromptHistoryNavigation(promptHistoryRef.current);
|
|
1482
|
+
updateComposerInput('');
|
|
1483
|
+
};
|
|
1469
1484
|
const promptImages = (prompt) => {
|
|
1470
1485
|
const seen = new Set();
|
|
1471
1486
|
return composerImageIds(prompt).flatMap((id) => {
|
|
@@ -1697,36 +1712,37 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
1697
1712
|
return () => clearInterval(timer);
|
|
1698
1713
|
}, [menu?.kind]);
|
|
1699
1714
|
const appendPromptHistory = (prompt) => {
|
|
1700
|
-
|
|
1701
|
-
return;
|
|
1702
|
-
const history = inputHistoryRef.current.filter((item) => item !== prompt);
|
|
1703
|
-
inputHistoryRef.current = [prompt, ...history].slice(0, 100);
|
|
1704
|
-
inputHistoryIndexRef.current = null;
|
|
1705
|
-
inputHistoryDraftRef.current = '';
|
|
1715
|
+
promptHistoryRef.current = recordComposerPrompt(promptHistoryRef.current, prompt);
|
|
1706
1716
|
};
|
|
1707
1717
|
const restorePromptHistory = (direction) => {
|
|
1708
|
-
const
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
return;
|
|
1718
|
+
const transition = navigateComposerPromptHistory(promptHistoryRef.current, direction, createComposerEditor(inputRef.current, inputCursorRef.current));
|
|
1719
|
+
promptHistoryRef.current = transition.state;
|
|
1720
|
+
if (transition.editor !== null)
|
|
1721
|
+
updateComposerInput(transition.editor.text, transition.editor.cursor);
|
|
1722
|
+
};
|
|
1723
|
+
const withdrawLatestPending = () => {
|
|
1724
|
+
if (inputRef.current.trim().length !== 0)
|
|
1725
|
+
return false;
|
|
1726
|
+
const pending = pendingInputsRef.current.at(-1);
|
|
1727
|
+
if (!pending)
|
|
1728
|
+
return false;
|
|
1729
|
+
if (pending.kind === 'follow-up') {
|
|
1730
|
+
followUpQueueRef.current = followUpQueueRef.current.filter((item) => item.id !== pending.id);
|
|
1731
|
+
updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
|
|
1732
|
+
updateComposerInput(pending.text);
|
|
1733
|
+
return true;
|
|
1719
1734
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1735
|
+
const activeSessionId = sessionIdRef.current;
|
|
1736
|
+
const result = activeSessionId
|
|
1737
|
+
? serviceRef.current?.withdrawSteering?.(activeSessionId, pending.id)
|
|
1738
|
+
: undefined;
|
|
1739
|
+
if (result?.kind === 'withdrawn') {
|
|
1740
|
+
updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
|
|
1741
|
+
updateComposerInput(result.item.content);
|
|
1742
|
+
return true;
|
|
1727
1743
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1744
|
+
append({ kind: 'warning', text: 'Input is already delivered.' });
|
|
1745
|
+
return true;
|
|
1730
1746
|
};
|
|
1731
1747
|
const dismissExitConfirmation = () => {
|
|
1732
1748
|
if (!exitConfirmation)
|
|
@@ -1781,6 +1797,33 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
1781
1797
|
case 'user-message':
|
|
1782
1798
|
append({ kind: 'assistant', text: event.message });
|
|
1783
1799
|
break;
|
|
1800
|
+
case 'user-input-delivered':
|
|
1801
|
+
// Steering is delivered after the preceding assistant batch has been
|
|
1802
|
+
// persisted. Commit that batch to the visible transcript before the
|
|
1803
|
+
// user row, then start the next provider continuation with a fresh
|
|
1804
|
+
// active buffer. Otherwise the active row would appear after the
|
|
1805
|
+
// steering message and be discarded when the turn finally completes.
|
|
1806
|
+
if ((streamingFrameRef.current?.text.trim().length ?? 0) > 0) {
|
|
1807
|
+
append({
|
|
1808
|
+
kind: 'assistant',
|
|
1809
|
+
text: streamingFrameRef.current?.text ?? '',
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
streamingFrameRef.current?.resetText();
|
|
1813
|
+
streamingFrameRef.current?.resetThinking();
|
|
1814
|
+
streamingFrameRef.current?.flush();
|
|
1815
|
+
updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
|
|
1816
|
+
append({ kind: 'user', text: event.content });
|
|
1817
|
+
break;
|
|
1818
|
+
case 'user-input-rejected':
|
|
1819
|
+
updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
|
|
1820
|
+
if (inputRef.current.trim().length === 0)
|
|
1821
|
+
updateComposerInput(event.content);
|
|
1822
|
+
append({
|
|
1823
|
+
kind: 'warning',
|
|
1824
|
+
text: `Input not delivered · ${redactSensitiveText(event.content, sensitiveValues)}`,
|
|
1825
|
+
});
|
|
1826
|
+
break;
|
|
1784
1827
|
case 'state':
|
|
1785
1828
|
if (event.state === 'awaiting-model') {
|
|
1786
1829
|
activeAttemptThinkingItemsRef.current = [];
|
|
@@ -2193,10 +2236,12 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
2193
2236
|
const loadId = sessionLoadRef.current + 1;
|
|
2194
2237
|
sessionLoadRef.current = loadId;
|
|
2195
2238
|
if (nextSessionId === null) {
|
|
2239
|
+
promptHistoryRef.current = createComposerPromptHistory();
|
|
2196
2240
|
setHistory([]);
|
|
2197
2241
|
setSessionColor(undefined);
|
|
2198
2242
|
return;
|
|
2199
2243
|
}
|
|
2244
|
+
promptHistoryRef.current = createComposerPromptHistory();
|
|
2200
2245
|
const loading = (async () => {
|
|
2201
2246
|
try {
|
|
2202
2247
|
const commands = await service();
|
|
@@ -2206,6 +2251,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
2206
2251
|
: await commands.agentColor(nextSessionId);
|
|
2207
2252
|
if (sessionLoadRef.current === loadId) {
|
|
2208
2253
|
setHistory(transcript ? [...transcript] : []);
|
|
2254
|
+
promptHistoryRef.current = seedComposerPromptHistory(transcriptPrompts(transcript ?? []));
|
|
2209
2255
|
setSessionColor(agentColor);
|
|
2210
2256
|
}
|
|
2211
2257
|
}
|
|
@@ -3189,6 +3235,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3189
3235
|
setSessionId(restoredConversation.sessionId || null);
|
|
3190
3236
|
setPendingFork(false);
|
|
3191
3237
|
setHistory(restoredConversation.history);
|
|
3238
|
+
promptHistoryRef.current = seedComposerPromptHistory(transcriptPrompts(restoredConversation.history));
|
|
3192
3239
|
updateComposerInput(point.prompt);
|
|
3193
3240
|
}
|
|
3194
3241
|
append({
|
|
@@ -3212,7 +3259,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3212
3259
|
onTurnChange?.(restoring);
|
|
3213
3260
|
void restoring.finally(() => onTurnChange?.(null));
|
|
3214
3261
|
};
|
|
3215
|
-
const
|
|
3262
|
+
const submitTurn = async (prompt, shellCommand, images = [], followUpId, internal = false) => {
|
|
3216
3263
|
setTranscriptScrollOffset(0);
|
|
3217
3264
|
const turnNumber = turnNumberRef.current + 1;
|
|
3218
3265
|
const turnStartedAt = Date.now();
|
|
@@ -3227,6 +3274,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3227
3274
|
? AbortSignal.any([signal, turnController.signal])
|
|
3228
3275
|
: turnController.signal;
|
|
3229
3276
|
setBusy(true);
|
|
3277
|
+
if (followUpId !== undefined) {
|
|
3278
|
+
updatePendingInputs((current) => current.filter((item) => item.id !== followUpId));
|
|
3279
|
+
}
|
|
3230
3280
|
setTurnDuration(undefined);
|
|
3231
3281
|
setCommandPaletteOpen(false);
|
|
3232
3282
|
const submittedCommandName = /^\/([^\s]+)/u.exec(prompt)?.[1]?.toLowerCase();
|
|
@@ -3245,9 +3295,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3245
3295
|
else
|
|
3246
3296
|
turnMutatedFilesRef.current = true;
|
|
3247
3297
|
let commands;
|
|
3298
|
+
let turnSucceeded = false;
|
|
3248
3299
|
try {
|
|
3249
3300
|
commands = await service();
|
|
3250
|
-
let activeSessionId =
|
|
3301
|
+
let activeSessionId = sessionIdRef.current;
|
|
3251
3302
|
const startedNewSession = activeSessionId === null;
|
|
3252
3303
|
if (activeSessionId === null) {
|
|
3253
3304
|
activeSessionId = randomUUID();
|
|
@@ -3316,7 +3367,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3316
3367
|
streamingFrameRef.current?.flush();
|
|
3317
3368
|
setStatus('ready');
|
|
3318
3369
|
setTurnDuration(Date.now() - turnStartedAt);
|
|
3319
|
-
if (
|
|
3370
|
+
if (!internal &&
|
|
3371
|
+
followUpQueueRef.current.length === 0 &&
|
|
3372
|
+
runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
|
|
3320
3373
|
notifyTerminal({
|
|
3321
3374
|
channel: runtimeSettingsRef.current.notifChannel,
|
|
3322
3375
|
title: 'Praxis',
|
|
@@ -3326,6 +3379,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3326
3379
|
: { write: notificationWriter }),
|
|
3327
3380
|
});
|
|
3328
3381
|
}
|
|
3382
|
+
turnSucceeded = true;
|
|
3329
3383
|
}
|
|
3330
3384
|
catch (error) {
|
|
3331
3385
|
if (turnController.signal.aborted && !signal?.aborted) {
|
|
@@ -3343,7 +3397,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3343
3397
|
}
|
|
3344
3398
|
}
|
|
3345
3399
|
finally {
|
|
3346
|
-
if (!factory.scheduledPrompts && commands) {
|
|
3400
|
+
if (!internal && !factory.scheduledPrompts && commands) {
|
|
3347
3401
|
try {
|
|
3348
3402
|
await commands.close?.();
|
|
3349
3403
|
}
|
|
@@ -3360,6 +3414,62 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
3360
3414
|
}
|
|
3361
3415
|
if (turnControllerRef.current === turnController)
|
|
3362
3416
|
turnControllerRef.current = null;
|
|
3417
|
+
if (!internal) {
|
|
3418
|
+
setBusy(false);
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
return turnSucceeded;
|
|
3422
|
+
};
|
|
3423
|
+
const submit = async (prompt, shellCommand, images = [], followUpId) => {
|
|
3424
|
+
let currentPrompt = prompt;
|
|
3425
|
+
let currentShellCommand = shellCommand;
|
|
3426
|
+
let currentImages = images;
|
|
3427
|
+
let currentFollowUpId = followUpId;
|
|
3428
|
+
let completed = false;
|
|
3429
|
+
try {
|
|
3430
|
+
while (true) {
|
|
3431
|
+
completed = await submitTurn(currentPrompt, currentShellCommand, currentImages, currentFollowUpId, true);
|
|
3432
|
+
if (!completed)
|
|
3433
|
+
break;
|
|
3434
|
+
const next = followUpQueueRef.current.shift();
|
|
3435
|
+
if (!next)
|
|
3436
|
+
break;
|
|
3437
|
+
currentPrompt = next.text;
|
|
3438
|
+
currentShellCommand = undefined;
|
|
3439
|
+
currentImages = [];
|
|
3440
|
+
currentFollowUpId = next.id;
|
|
3441
|
+
}
|
|
3442
|
+
if (completed &&
|
|
3443
|
+
runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
|
|
3444
|
+
notifyTerminal({
|
|
3445
|
+
channel: runtimeSettingsRef.current.notifChannel,
|
|
3446
|
+
title: 'Praxis',
|
|
3447
|
+
message: 'Turn complete',
|
|
3448
|
+
...(notificationWriter === undefined
|
|
3449
|
+
? {}
|
|
3450
|
+
: { write: notificationWriter }),
|
|
3451
|
+
});
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
finally {
|
|
3455
|
+
if (!factory.scheduledPrompts) {
|
|
3456
|
+
const commands = serviceRef.current;
|
|
3457
|
+
if (commands) {
|
|
3458
|
+
try {
|
|
3459
|
+
await commands.close?.();
|
|
3460
|
+
}
|
|
3461
|
+
catch (error) {
|
|
3462
|
+
append({
|
|
3463
|
+
kind: 'warning',
|
|
3464
|
+
text: redactSensitiveText(error instanceof Error ? error.message : String(error), sensitiveValues),
|
|
3465
|
+
});
|
|
3466
|
+
}
|
|
3467
|
+
finally {
|
|
3468
|
+
if (serviceRef.current === commands)
|
|
3469
|
+
serviceRef.current = null;
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3363
3473
|
setBusy(false);
|
|
3364
3474
|
}
|
|
3365
3475
|
};
|
|
@@ -5890,6 +6000,94 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
5890
6000
|
void backgrounding;
|
|
5891
6001
|
return;
|
|
5892
6002
|
}
|
|
6003
|
+
const pendingText = inputRef.current.trim();
|
|
6004
|
+
const addPending = (item) => updatePendingInputs((current) => [...current, item]);
|
|
6005
|
+
if (key.upArrow && pendingText.length === 0) {
|
|
6006
|
+
withdrawLatestPending();
|
|
6007
|
+
return;
|
|
6008
|
+
}
|
|
6009
|
+
if (key.return && key.shift) {
|
|
6010
|
+
updateComposerEditor(insertComposerText(editor(), '\n'));
|
|
6011
|
+
return;
|
|
6012
|
+
}
|
|
6013
|
+
if (isKeybinding('chat:submit') && !key.shift && !key.meta && !key.ctrl) {
|
|
6014
|
+
if (!pendingText)
|
|
6015
|
+
return;
|
|
6016
|
+
const controller = turnControllerRef.current;
|
|
6017
|
+
if (!controller) {
|
|
6018
|
+
append({
|
|
6019
|
+
kind: 'warning',
|
|
6020
|
+
text: 'Turn is completing; input was retained.',
|
|
6021
|
+
});
|
|
6022
|
+
return;
|
|
6023
|
+
}
|
|
6024
|
+
const restoreSteeringText = () => {
|
|
6025
|
+
const current = inputRef.current;
|
|
6026
|
+
updateComposerInput(current.trim().length === 0
|
|
6027
|
+
? pendingText
|
|
6028
|
+
: `${pendingText}\n${current}`);
|
|
6029
|
+
};
|
|
6030
|
+
// Service construction may still be in flight while the busy composer
|
|
6031
|
+
// is already visible. Clear now so newly typed text becomes a distinct
|
|
6032
|
+
// draft; restore both drafts if the active-turn command loses a race.
|
|
6033
|
+
clearComposerInput();
|
|
6034
|
+
const steer = async () => {
|
|
6035
|
+
const commands = serviceRef.current ?? (await service());
|
|
6036
|
+
if (turnControllerRef.current !== controller ||
|
|
6037
|
+
controller.signal.aborted) {
|
|
6038
|
+
restoreSteeringText();
|
|
6039
|
+
append({
|
|
6040
|
+
kind: 'warning',
|
|
6041
|
+
text: 'Active turn changed; input was retained.',
|
|
6042
|
+
});
|
|
6043
|
+
return;
|
|
6044
|
+
}
|
|
6045
|
+
const activeSessionId = sessionIdRef.current;
|
|
6046
|
+
const result = activeSessionId
|
|
6047
|
+
? commands.steer?.(activeSessionId, pendingText)
|
|
6048
|
+
: undefined;
|
|
6049
|
+
if (result?.kind === 'accepted') {
|
|
6050
|
+
addPending({
|
|
6051
|
+
id: result.item.id,
|
|
6052
|
+
kind: 'steering',
|
|
6053
|
+
text: result.item.content,
|
|
6054
|
+
});
|
|
6055
|
+
}
|
|
6056
|
+
else {
|
|
6057
|
+
restoreSteeringText();
|
|
6058
|
+
append({
|
|
6059
|
+
kind: 'warning',
|
|
6060
|
+
text: result?.kind === 'turn-completing'
|
|
6061
|
+
? 'Turn is completing; input was retained.'
|
|
6062
|
+
: result?.kind === 'not-steerable'
|
|
6063
|
+
? 'This turn cannot be steered; input was retained.'
|
|
6064
|
+
: 'Steering is unavailable; input was retained.',
|
|
6065
|
+
});
|
|
6066
|
+
}
|
|
6067
|
+
};
|
|
6068
|
+
void steer().catch((error) => {
|
|
6069
|
+
restoreSteeringText();
|
|
6070
|
+
warn(error);
|
|
6071
|
+
});
|
|
6072
|
+
return;
|
|
6073
|
+
}
|
|
6074
|
+
if (key.tab || (key.return && key.meta)) {
|
|
6075
|
+
if (!pendingText)
|
|
6076
|
+
return;
|
|
6077
|
+
if (!turnControllerRef.current) {
|
|
6078
|
+
append({
|
|
6079
|
+
kind: 'warning',
|
|
6080
|
+
text: 'Turn is completing; follow-up input was retained.',
|
|
6081
|
+
});
|
|
6082
|
+
return;
|
|
6083
|
+
}
|
|
6084
|
+
const item = { id: randomUUID(), text: pendingText };
|
|
6085
|
+
followUpQueueRef.current.push(item);
|
|
6086
|
+
addPending({ id: item.id, kind: 'follow-up', text: item.text });
|
|
6087
|
+
clearComposerInput();
|
|
6088
|
+
return;
|
|
6089
|
+
}
|
|
6090
|
+
editComposer();
|
|
5893
6091
|
return;
|
|
5894
6092
|
}
|
|
5895
6093
|
if (isKeybinding('chat:imagePaste')) {
|
|
@@ -6074,9 +6272,6 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
6074
6272
|
streamingFrameRef.current?.flush();
|
|
6075
6273
|
setThinkingExpanded(false);
|
|
6076
6274
|
setStatus('ready');
|
|
6077
|
-
inputHistoryRef.current = [];
|
|
6078
|
-
inputHistoryIndexRef.current = null;
|
|
6079
|
-
inputHistoryDraftRef.current = '';
|
|
6080
6275
|
})().catch(warn);
|
|
6081
6276
|
}
|
|
6082
6277
|
else if (prompt === '/model') {
|
|
@@ -6571,6 +6766,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
6571
6766
|
}
|
|
6572
6767
|
return;
|
|
6573
6768
|
}
|
|
6769
|
+
if (isKeybinding('history:previous') &&
|
|
6770
|
+
inputRef.current.length === 0 &&
|
|
6771
|
+
withdrawLatestPending()) {
|
|
6772
|
+
return;
|
|
6773
|
+
}
|
|
6574
6774
|
if (isKeybinding('history:previous')) {
|
|
6575
6775
|
restorePromptHistory('previous');
|
|
6576
6776
|
return;
|
|
@@ -6664,6 +6864,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
6664
6864
|
shellMode,
|
|
6665
6865
|
busy,
|
|
6666
6866
|
status: conciseStatus,
|
|
6867
|
+
pendingItems: pendingInputs,
|
|
6667
6868
|
display: runtimeDisplay,
|
|
6668
6869
|
}), [
|
|
6669
6870
|
screen,
|
|
@@ -6672,6 +6873,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
6672
6873
|
inputCursor,
|
|
6673
6874
|
busy,
|
|
6674
6875
|
conciseStatus,
|
|
6876
|
+
pendingInputs,
|
|
6675
6877
|
runtimeDisplay,
|
|
6676
6878
|
]);
|
|
6677
6879
|
const terminalSelectionContext = useMemo(() => createTerminalSelectionContext(quietFrame, conversationScreen?.transcript.rows.length ?? 0, transcriptScrollOffset, maxTranscriptScrollOffset), [
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ComposerEditorState } from './composer-editor.js';
|
|
2
|
+
export declare const COMPOSER_PROMPT_HISTORY_LIMIT = 100;
|
|
3
|
+
export interface ComposerPromptHistoryState {
|
|
4
|
+
readonly entries: readonly string[];
|
|
5
|
+
readonly index: number | null;
|
|
6
|
+
readonly draft: ComposerEditorState | null;
|
|
7
|
+
}
|
|
8
|
+
export interface ComposerPromptHistoryTransition {
|
|
9
|
+
readonly state: ComposerPromptHistoryState;
|
|
10
|
+
readonly editor: ComposerEditorState | null;
|
|
11
|
+
}
|
|
12
|
+
export declare function createComposerPromptHistory(): ComposerPromptHistoryState;
|
|
13
|
+
export declare function seedComposerPromptHistory(prompts: readonly string[]): ComposerPromptHistoryState;
|
|
14
|
+
export declare function recordComposerPrompt(state: ComposerPromptHistoryState, prompt: string): ComposerPromptHistoryState;
|
|
15
|
+
export declare function navigateComposerPromptHistory(state: ComposerPromptHistoryState, direction: 'previous' | 'next', currentEditor: ComposerEditorState): ComposerPromptHistoryTransition;
|
|
16
|
+
export declare function resetComposerPromptHistoryNavigation(state: ComposerPromptHistoryState): ComposerPromptHistoryState;
|
|
17
|
+
//# sourceMappingURL=composer-prompt-history.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createComposerEditor, } from './composer-editor.js';
|
|
2
|
+
export const COMPOSER_PROMPT_HISTORY_LIMIT = 100;
|
|
3
|
+
export function createComposerPromptHistory() {
|
|
4
|
+
return { entries: [], index: null, draft: null };
|
|
5
|
+
}
|
|
6
|
+
function normalize(prompts) {
|
|
7
|
+
const entries = [];
|
|
8
|
+
for (const prompt of prompts) {
|
|
9
|
+
if (prompt.trim() === '')
|
|
10
|
+
continue;
|
|
11
|
+
if (entries.at(-1) === prompt)
|
|
12
|
+
continue;
|
|
13
|
+
entries.push(prompt);
|
|
14
|
+
}
|
|
15
|
+
return entries.slice(-COMPOSER_PROMPT_HISTORY_LIMIT);
|
|
16
|
+
}
|
|
17
|
+
export function seedComposerPromptHistory(prompts) {
|
|
18
|
+
return { entries: normalize(prompts), index: null, draft: null };
|
|
19
|
+
}
|
|
20
|
+
export function recordComposerPrompt(state, prompt) {
|
|
21
|
+
if (prompt.trim() === '')
|
|
22
|
+
return state;
|
|
23
|
+
return {
|
|
24
|
+
entries: normalize([...state.entries, prompt]),
|
|
25
|
+
index: null,
|
|
26
|
+
draft: null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function navigateComposerPromptHistory(state, direction, currentEditor) {
|
|
30
|
+
if (state.entries.length === 0)
|
|
31
|
+
return { state, editor: null };
|
|
32
|
+
if (direction === 'previous') {
|
|
33
|
+
const index = state.index === null
|
|
34
|
+
? state.entries.length - 1
|
|
35
|
+
: Math.max(0, state.index - 1);
|
|
36
|
+
const draft = state.index === null
|
|
37
|
+
? createComposerEditor(currentEditor.text, currentEditor.cursor)
|
|
38
|
+
: state.draft;
|
|
39
|
+
const nextState = { ...state, index, draft };
|
|
40
|
+
return {
|
|
41
|
+
state: nextState,
|
|
42
|
+
editor: createComposerEditor(state.entries[index]),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (state.index === null)
|
|
46
|
+
return { state, editor: null };
|
|
47
|
+
if (state.index < state.entries.length - 1) {
|
|
48
|
+
const index = state.index + 1;
|
|
49
|
+
return {
|
|
50
|
+
state: { ...state, index },
|
|
51
|
+
editor: createComposerEditor(state.entries[index]),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const editor = state.draft;
|
|
55
|
+
return {
|
|
56
|
+
state: { ...state, index: null, draft: null },
|
|
57
|
+
editor,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function resetComposerPromptHistoryNavigation(state) {
|
|
61
|
+
return { ...state, index: null, draft: null };
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=composer-prompt-history.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TuiScreenModel } from './tui-screen-model.js';
|
|
2
2
|
import type { TuiRow, TuiTextRole } from './tui-row-ir.js';
|
|
3
3
|
export type QuietFrameDensity = 'full' | 'standard' | 'compact' | 'narrow' | 'minimal';
|
|
4
|
-
export type QuietFrameRegion = 'identity' | 'transcript' | 'active' | 'focus' | 'composer' | 'status';
|
|
4
|
+
export type QuietFrameRegion = 'identity' | 'transcript' | 'active' | 'pending' | 'focus' | 'composer' | 'status';
|
|
5
5
|
export interface QuietFrameRow extends TuiRow {
|
|
6
6
|
readonly region: QuietFrameRegion;
|
|
7
7
|
readonly accessibleText?: string;
|
|
@@ -26,6 +26,11 @@ export interface QuietFrameInput {
|
|
|
26
26
|
readonly shellMode: boolean;
|
|
27
27
|
readonly busy: boolean;
|
|
28
28
|
readonly status: string;
|
|
29
|
+
readonly pendingItems?: readonly {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly kind: 'steering' | 'follow-up';
|
|
32
|
+
readonly text: string;
|
|
33
|
+
}[];
|
|
29
34
|
readonly display?: {
|
|
30
35
|
readonly cwd?: string;
|
|
31
36
|
readonly model?: string;
|
|
@@ -280,6 +280,10 @@ export function projectQuietFrame(input) {
|
|
|
280
280
|
thinking.lines.forEach(({ text: part, start }, index) => lines.push(createQuietFrameRow(`quiet:active:thinking:${start}`, `${index === 0 ? '✻ ' : ' '}${part}`, 'active', 'muted', `${index === 0 ? 'Thinking: ' : ''}${part}`)));
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
|
+
for (const item of input.pendingItems ?? []) {
|
|
284
|
+
const label = item.kind === 'steering' ? 'steer' : 'follow-up';
|
|
285
|
+
lines.push(createQuietFrameRow(`quiet:pending:${item.id}`, `${label} · ${clean(item.text)}`, 'pending', 'muted', `${label} pending input: ${clean(item.text)}`));
|
|
286
|
+
}
|
|
283
287
|
const focus = input.focusRows.length > 0
|
|
284
288
|
? input.focusRows.map((item) => withRegion(item, 'focus'))
|
|
285
289
|
: [
|
|
@@ -19,6 +19,7 @@ export interface QuietScreenProjectionInput {
|
|
|
19
19
|
readonly shellMode: boolean;
|
|
20
20
|
readonly busy: boolean;
|
|
21
21
|
readonly status: string;
|
|
22
|
+
readonly pendingItems?: QuietFrameInput['pendingItems'];
|
|
22
23
|
readonly display?: QuietFrameInput['display'];
|
|
23
24
|
}
|
|
24
25
|
export declare function projectQuietScreenFrame(input: QuietScreenProjectionInput): QuietFrame;
|
|
@@ -103,6 +103,9 @@ export function projectQuietScreenFrame(input) {
|
|
|
103
103
|
shellMode: input.shellMode,
|
|
104
104
|
busy: input.busy,
|
|
105
105
|
status: input.status,
|
|
106
|
+
...(input.pendingItems === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { pendingItems: input.pendingItems }),
|
|
106
109
|
...(input.display === undefined ? {} : { display: input.display }),
|
|
107
110
|
focusRows,
|
|
108
111
|
});
|
|
@@ -44,7 +44,8 @@ export function routeTuiInteraction(snapshot, input) {
|
|
|
44
44
|
if (snapshot.layer.kind === 'delegated')
|
|
45
45
|
return delegated(confirmationEffects);
|
|
46
46
|
const viewport = snapshot.viewport;
|
|
47
|
-
|
|
47
|
+
const scrollIsIncidental = input.action === undefined || input.action.startsWith('scroll:');
|
|
48
|
+
if (viewport.enabled && input.scrollIntent !== 'none' && scrollIsIncidental) {
|
|
48
49
|
const pageRows = finiteNonNegativeInteger(viewport.pageRows);
|
|
49
50
|
const offset = finiteNonNegativeInteger(viewport.offset);
|
|
50
51
|
const maxOffset = finiteNonNegativeInteger(viewport.maxOffset);
|
|
@@ -71,7 +72,7 @@ export function routeTuiInteraction(snapshot, input) {
|
|
|
71
72
|
}
|
|
72
73
|
if (input.action === 'chat:cancel')
|
|
73
74
|
return handled([...confirmationEffects, { kind: 'interrupt-turn' }]);
|
|
74
|
-
return
|
|
75
|
+
return delegated(confirmationEffects);
|
|
75
76
|
}
|
|
76
77
|
if (input.callerIntent !== 'none')
|
|
77
78
|
return delegated(confirmationEffects);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** A user input item accepted by an active, steerable turn. */
|
|
2
|
+
export interface SteeringItem {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly content: string;
|
|
5
|
+
}
|
|
6
|
+
export type SteeringEnqueueResult = {
|
|
7
|
+
readonly kind: 'accepted';
|
|
8
|
+
readonly item: SteeringItem;
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: 'empty';
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'sealed';
|
|
13
|
+
};
|
|
14
|
+
export type SteeringWithdrawResult = {
|
|
15
|
+
readonly kind: 'withdrawn';
|
|
16
|
+
readonly item: SteeringItem;
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: 'not-pending';
|
|
19
|
+
};
|
|
20
|
+
/** Results returned by the session-level steering commands. */
|
|
21
|
+
export type ActiveTurnInputCommandResult = {
|
|
22
|
+
readonly kind: 'accepted';
|
|
23
|
+
readonly item: SteeringItem;
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: 'withdrawn';
|
|
26
|
+
readonly item: SteeringItem;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: 'empty';
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: 'no-active-turn';
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: 'not-steerable';
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: 'turn-completing';
|
|
35
|
+
} | {
|
|
36
|
+
readonly kind: 'not-pending';
|
|
37
|
+
};
|
|
38
|
+
/** The synchronous port exposed to the runtime at safe continuation points. */
|
|
39
|
+
export interface ActiveTurnInputPort {
|
|
40
|
+
take(): SteeringItem | undefined;
|
|
41
|
+
takeOrSeal(): SteeringItem | undefined;
|
|
42
|
+
/** Drain and seal the port, returning every item that was not delivered. */
|
|
43
|
+
close(): readonly SteeringItem[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A small synchronous FIFO. Keeping all state transitions synchronous makes
|
|
47
|
+
* enqueue/take-or-seal atomic with respect to the runtime's await boundaries.
|
|
48
|
+
*/
|
|
49
|
+
export declare class ActiveTurnInputMailbox implements ActiveTurnInputPort {
|
|
50
|
+
private readonly createId;
|
|
51
|
+
private readonly items;
|
|
52
|
+
private sealed;
|
|
53
|
+
constructor(createId: () => string);
|
|
54
|
+
enqueue(content: string): SteeringEnqueueResult;
|
|
55
|
+
take(): SteeringItem | undefined;
|
|
56
|
+
/** Take one item, or seal an empty mailbox before returning. */
|
|
57
|
+
takeOrSeal(): SteeringItem | undefined;
|
|
58
|
+
withdraw(id: string): SteeringWithdrawResult;
|
|
59
|
+
close(): readonly SteeringItem[];
|
|
60
|
+
isSealed(): boolean;
|
|
61
|
+
get pendingCount(): number;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=active-turn-input.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small synchronous FIFO. Keeping all state transitions synchronous makes
|
|
3
|
+
* enqueue/take-or-seal atomic with respect to the runtime's await boundaries.
|
|
4
|
+
*/
|
|
5
|
+
export class ActiveTurnInputMailbox {
|
|
6
|
+
createId;
|
|
7
|
+
items = [];
|
|
8
|
+
sealed = false;
|
|
9
|
+
constructor(createId) {
|
|
10
|
+
this.createId = createId;
|
|
11
|
+
}
|
|
12
|
+
enqueue(content) {
|
|
13
|
+
const trimmed = content.trim();
|
|
14
|
+
if (trimmed.length === 0)
|
|
15
|
+
return { kind: 'empty' };
|
|
16
|
+
if (this.sealed)
|
|
17
|
+
return { kind: 'sealed' };
|
|
18
|
+
const item = { id: this.createId(), content: trimmed };
|
|
19
|
+
this.items.push(item);
|
|
20
|
+
return { kind: 'accepted', item };
|
|
21
|
+
}
|
|
22
|
+
take() {
|
|
23
|
+
return this.items.shift();
|
|
24
|
+
}
|
|
25
|
+
/** Take one item, or seal an empty mailbox before returning. */
|
|
26
|
+
takeOrSeal() {
|
|
27
|
+
const item = this.items.shift();
|
|
28
|
+
if (item !== undefined)
|
|
29
|
+
return item;
|
|
30
|
+
this.sealed = true;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
withdraw(id) {
|
|
34
|
+
const index = this.items.findIndex((item) => item.id === id);
|
|
35
|
+
if (index < 0)
|
|
36
|
+
return { kind: 'not-pending' };
|
|
37
|
+
const [item] = this.items.splice(index, 1);
|
|
38
|
+
return item === undefined
|
|
39
|
+
? { kind: 'not-pending' }
|
|
40
|
+
: { kind: 'withdrawn', item };
|
|
41
|
+
}
|
|
42
|
+
close() {
|
|
43
|
+
this.sealed = true;
|
|
44
|
+
return this.items.splice(0);
|
|
45
|
+
}
|
|
46
|
+
isSealed() {
|
|
47
|
+
return this.sealed;
|
|
48
|
+
}
|
|
49
|
+
get pendingCount() {
|
|
50
|
+
return this.items.length;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=active-turn-input.js.map
|
package/dist/core/runtime.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ActiveTurnInputPort, SteeringItem } from './active-turn-input.js';
|
|
1
2
|
export type RuntimeState = 'idle' | 'assembling-context' | 'compacting' | 'awaiting-model' | 'streaming' | 'awaiting-permission' | 'executing-tools' | 'persisting-results' | 'completed' | 'cancelled' | 'failed';
|
|
2
3
|
export type ModelMessage = {
|
|
3
4
|
role: 'system';
|
|
@@ -175,6 +176,15 @@ export type RuntimeEvent = {
|
|
|
175
176
|
message: string;
|
|
176
177
|
attachments?: readonly string[];
|
|
177
178
|
status: 'normal' | 'proactive';
|
|
179
|
+
} | {
|
|
180
|
+
type: 'user-input-delivered';
|
|
181
|
+
id: string;
|
|
182
|
+
content: string;
|
|
183
|
+
} | {
|
|
184
|
+
type: 'user-input-rejected';
|
|
185
|
+
id: string;
|
|
186
|
+
content: string;
|
|
187
|
+
reason: 'cancelled' | 'failed' | 'closed';
|
|
178
188
|
} | {
|
|
179
189
|
type: 'usage';
|
|
180
190
|
usage: ModelUsage;
|
|
@@ -434,6 +444,7 @@ export interface AgentRunObserver {
|
|
|
434
444
|
}): Promise<void>;
|
|
435
445
|
toolCompleted(call: ModelToolCall, result: ToolExecutionResult): Promise<void>;
|
|
436
446
|
followUpUserMessagesCompleted?(messages: readonly string[]): Promise<void>;
|
|
447
|
+
userInputDelivered?(item: SteeringItem): Promise<void>;
|
|
437
448
|
}
|
|
438
449
|
export interface ToolUseSummaryOutcome {
|
|
439
450
|
summary: string | null;
|
|
@@ -471,6 +482,7 @@ export interface AgentRunRequest {
|
|
|
471
482
|
cwd?: string;
|
|
472
483
|
toolResultDirectory?: string;
|
|
473
484
|
observer?: AgentRunObserver;
|
|
485
|
+
steering?: ActiveTurnInputPort;
|
|
474
486
|
reloadMessages?: () => Promise<readonly ModelMessage[]>;
|
|
475
487
|
approveTool?: (call: ModelToolCall, originalCall?: ModelToolCall, decision?: PermissionDecision) => PermissionApproval | Promise<PermissionApproval>;
|
|
476
488
|
permissionUpdates?: readonly PermissionUpdate[];
|
package/dist/core/runtime.js
CHANGED
|
@@ -255,13 +255,58 @@ export class AgentRuntime {
|
|
|
255
255
|
this.options = options;
|
|
256
256
|
}
|
|
257
257
|
async run(request) {
|
|
258
|
+
const steering = request.steering;
|
|
259
|
+
const rejectSteering = (reason) => {
|
|
260
|
+
for (const item of steering?.close() ?? []) {
|
|
261
|
+
this.emit({
|
|
262
|
+
type: 'user-input-rejected',
|
|
263
|
+
id: item.id,
|
|
264
|
+
content: item.content,
|
|
265
|
+
reason,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const cancelWithSteering = () => {
|
|
270
|
+
rejectSteering('cancelled');
|
|
271
|
+
return this.cancel();
|
|
272
|
+
};
|
|
273
|
+
const deliverSteering = async (item) => {
|
|
274
|
+
try {
|
|
275
|
+
if (!request.observer?.userInputDelivered) {
|
|
276
|
+
throw new Error('Active-turn steering requires a durable delivery observer');
|
|
277
|
+
}
|
|
278
|
+
await request.observer.userInputDelivered(item);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
this.emit({
|
|
282
|
+
type: 'user-input-rejected',
|
|
283
|
+
id: item.id,
|
|
284
|
+
content: item.content,
|
|
285
|
+
reason: 'failed',
|
|
286
|
+
});
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
messages.push({ role: 'user', content: item.content });
|
|
290
|
+
this.emit({
|
|
291
|
+
type: 'user-input-delivered',
|
|
292
|
+
id: item.id,
|
|
293
|
+
content: item.content,
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
const deliverNextSteering = async () => {
|
|
297
|
+
const item = steering?.take();
|
|
298
|
+
if (!item)
|
|
299
|
+
return false;
|
|
300
|
+
await deliverSteering(item);
|
|
301
|
+
return true;
|
|
302
|
+
};
|
|
258
303
|
const failurePresentationDeferred = (kind) => request.deferFailureKinds === true ||
|
|
259
304
|
request.deferFailureKinds?.includes(kind) === true;
|
|
260
305
|
if (this.options.emitInitialContextState !== false) {
|
|
261
306
|
this.emit({ type: 'state', state: 'assembling-context' });
|
|
262
307
|
}
|
|
263
308
|
if (request.signal?.aborted)
|
|
264
|
-
return
|
|
309
|
+
return cancelWithSteering();
|
|
265
310
|
const messages = [...request.messages];
|
|
266
311
|
let usage = emptyUsage();
|
|
267
312
|
let modelUsage = emptyUsage();
|
|
@@ -295,7 +340,7 @@ export class AgentRuntime {
|
|
|
295
340
|
activeAttemptHasPresentation = false;
|
|
296
341
|
activeAttemptDiscarded = false;
|
|
297
342
|
if (request.signal?.aborted)
|
|
298
|
-
return
|
|
343
|
+
return cancelWithSteering();
|
|
299
344
|
if (maxModelTurns !== undefined && modelTurns >= maxModelTurns) {
|
|
300
345
|
throw new Error(`Maximum model turns of ${maxModelTurns} exceeded`);
|
|
301
346
|
}
|
|
@@ -399,7 +444,7 @@ export class AgentRuntime {
|
|
|
399
444
|
try {
|
|
400
445
|
for await (const event of this.provider.complete(providerRequest)) {
|
|
401
446
|
if (request.signal?.aborted)
|
|
402
|
-
return
|
|
447
|
+
return cancelWithSteering();
|
|
403
448
|
if (terminalReason !== undefined) {
|
|
404
449
|
throw new ModelProviderError(`Provider emitted ${event.type} after terminal reason ${terminalReason}`, { retryable: false });
|
|
405
450
|
}
|
|
@@ -499,7 +544,7 @@ export class AgentRuntime {
|
|
|
499
544
|
}
|
|
500
545
|
await toolScheduler.settle().catch(() => undefined);
|
|
501
546
|
if (request.signal?.aborted)
|
|
502
|
-
return
|
|
547
|
+
return cancelWithSteering();
|
|
503
548
|
throw error;
|
|
504
549
|
}
|
|
505
550
|
finally {
|
|
@@ -582,6 +627,11 @@ export class AgentRuntime {
|
|
|
582
627
|
messages.push(assistantMessage);
|
|
583
628
|
toolScheduler.releaseExclusiveTools();
|
|
584
629
|
if (toolCalls.length === 0) {
|
|
630
|
+
const preStopSteering = steering?.take();
|
|
631
|
+
if (preStopSteering !== undefined) {
|
|
632
|
+
await deliverSteering(preStopSteering);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
585
635
|
const stopResult = (await request.onStop?.(text)) ?? [];
|
|
586
636
|
const stopBatch = Array.isArray(stopResult)
|
|
587
637
|
? null
|
|
@@ -617,11 +667,16 @@ export class AgentRuntime {
|
|
|
617
667
|
if (request.reloadMessages) {
|
|
618
668
|
const reloadedMessages = await request.reloadMessages();
|
|
619
669
|
if (request.signal?.aborted)
|
|
620
|
-
return
|
|
670
|
+
return cancelWithSteering();
|
|
621
671
|
messages.splice(0, messages.length, ...reloadedMessages);
|
|
622
672
|
}
|
|
623
673
|
continue;
|
|
624
674
|
}
|
|
675
|
+
const completionInput = steering?.takeOrSeal();
|
|
676
|
+
if (completionInput !== undefined) {
|
|
677
|
+
await deliverSteering(completionInput);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
625
680
|
this.emit({ type: 'state', state: 'completed' });
|
|
626
681
|
const modelUsage = modelUsageByModel.size === 0
|
|
627
682
|
? undefined
|
|
@@ -709,14 +764,18 @@ export class AgentRuntime {
|
|
|
709
764
|
if (request.reloadMessages) {
|
|
710
765
|
const reloadedMessages = await request.reloadMessages();
|
|
711
766
|
if (request.signal?.aborted)
|
|
712
|
-
return
|
|
767
|
+
return cancelWithSteering();
|
|
713
768
|
messages.splice(0, messages.length, ...reloadedMessages);
|
|
714
769
|
}
|
|
770
|
+
await deliverNextSteering();
|
|
715
771
|
}
|
|
716
772
|
}
|
|
717
773
|
catch (error) {
|
|
718
|
-
if (request.signal?.aborted)
|
|
774
|
+
if (request.signal?.aborted) {
|
|
775
|
+
rejectSteering('cancelled');
|
|
719
776
|
return this.cancel();
|
|
777
|
+
}
|
|
778
|
+
rejectSteering('failed');
|
|
720
779
|
const message = error instanceof Error ? error.message : String(error);
|
|
721
780
|
const retryable = error instanceof ModelProviderError ? error.retryable : false;
|
|
722
781
|
const kind = error instanceof ModelProviderError
|