@sagmans/dsh-tui 0.5.2 → 0.6.0
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 +32 -12
- package/lib/agent/host.d.ts +11 -0
- package/lib/agent/host.d.ts.map +1 -1
- package/lib/agent/host.js +16 -2
- package/lib/agent/host.js.map +1 -1
- package/lib/agent/undo.d.ts +52 -0
- package/lib/agent/undo.d.ts.map +1 -0
- package/lib/agent/undo.js +106 -0
- package/lib/agent/undo.js.map +1 -0
- package/lib/cards.d.ts +57 -2
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +38 -8
- package/lib/cards.js.map +1 -1
- package/lib/herdr/reporter.d.ts +9 -4
- package/lib/herdr/reporter.d.ts.map +1 -1
- package/lib/herdr/reporter.js +5 -9
- package/lib/herdr/reporter.js.map +1 -1
- package/lib/herdr/state.d.ts +35 -4
- package/lib/herdr/state.d.ts.map +1 -1
- package/lib/herdr/state.js +37 -4
- package/lib/herdr/state.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +349 -14
- package/lib/index.js.map +1 -1
- package/lib/input/actions.d.ts.map +1 -1
- package/lib/input/actions.js +4 -0
- package/lib/input/actions.js.map +1 -1
- package/lib/input/completion.d.ts +10 -0
- package/lib/input/completion.d.ts.map +1 -1
- package/lib/input/completion.js +15 -0
- package/lib/input/completion.js.map +1 -1
- package/lib/input/keymap.d.ts.map +1 -1
- package/lib/input/keymap.js +2 -0
- package/lib/input/keymap.js.map +1 -1
- package/lib/input/submission.d.ts +11 -1
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +10 -1
- package/lib/input/submission.js.map +1 -1
- package/lib/terminal/warning-screen.d.ts +8 -1
- package/lib/terminal/warning-screen.d.ts.map +1 -1
- package/lib/terminal/warning-screen.js +9 -0
- package/lib/terminal/warning-screen.js.map +1 -1
- package/lib/theme-schema.d.ts +6 -6
- package/lib/theme-settings.d.ts +8 -8
- package/lib/theme-tokens.d.ts +1 -1
- package/lib/theme-tokens.d.ts.map +1 -1
- package/lib/theme-tokens.js +27 -1
- package/lib/theme-tokens.js.map +1 -1
- package/lib/transcript.d.ts +50 -14
- package/lib/transcript.d.ts.map +1 -1
- package/lib/transcript.js +82 -47
- package/lib/transcript.js.map +1 -1
- package/lib/ui/copy.d.ts +17 -0
- package/lib/ui/copy.d.ts.map +1 -0
- package/lib/ui/copy.js +104 -0
- package/lib/ui/copy.js.map +1 -0
- package/lib/ui/dock.d.ts +21 -1
- package/lib/ui/dock.d.ts.map +1 -1
- package/lib/ui/dock.js +75 -16
- package/lib/ui/dock.js.map +1 -1
- package/lib/ui/frame.d.ts +31 -0
- package/lib/ui/frame.d.ts.map +1 -1
- package/lib/ui/frame.js +27 -4
- package/lib/ui/frame.js.map +1 -1
- package/lib/ui/view.d.ts +73 -2
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +180 -20
- package/lib/ui/view.js.map +1 -1
- package/package.json +1 -1
- package/themes/deepseek-blue.yaml +9 -0
- package/themes/violet-orbit.yaml +7 -0
package/lib/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createPromptHistory } from "./agent/prompt-history.js";
|
|
|
8
8
|
import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
|
|
9
9
|
import { createToolPresenter } from "./agent/present.js";
|
|
10
10
|
import { forkPoint } from "./agent/fork.js";
|
|
11
|
+
import { hiddenTail, NO_UNDO, redoStep, resetUndo, turnsOf, UNDO_TURN_SETTLE_MS, undoStep } from "./agent/undo.js";
|
|
11
12
|
import { PROFILE_NAME, resumeHint } from "./identity.js";
|
|
12
13
|
import { createStatusFacts } from "./agent/status.js";
|
|
13
14
|
import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
|
|
@@ -16,7 +17,8 @@ import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagent
|
|
|
16
17
|
import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
|
|
17
18
|
import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
|
|
18
19
|
import { cancelStep, quitStep } from "./input/cancel.js";
|
|
19
|
-
import { createCompletionProvider } from "./input/completion.js";
|
|
20
|
+
import { createAnswerCompletionProvider, createCompletionProvider } from "./input/completion.js";
|
|
21
|
+
import { createFileIndex } from "./input/file-search.js";
|
|
20
22
|
import { ghostSuffix } from "./input/ghost.js";
|
|
21
23
|
import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
|
|
22
24
|
import { createDeferredNotice } from "./settings-notice.js";
|
|
@@ -31,7 +33,7 @@ import { WarningSafeTui } from "./terminal/warning-screen.js";
|
|
|
31
33
|
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
32
34
|
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
33
35
|
import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
34
|
-
import { sessionStartReason } from "./herdr/state.js";
|
|
36
|
+
import { driverReportFor, sessionStartReason } from "./herdr/state.js";
|
|
35
37
|
import { createHerdrReporter } from "./herdr/reporter.js";
|
|
36
38
|
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
37
39
|
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
@@ -47,6 +49,7 @@ import { SessionId } from '@deepseek-ai/dsh-session';
|
|
|
47
49
|
import { formatTokens } from "./tokens.js";
|
|
48
50
|
import { TranscriptModel } from "./transcript.js";
|
|
49
51
|
import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
|
|
52
|
+
import { cleanCopied } from "./ui/copy.js";
|
|
50
53
|
import { WorkDock } from "./ui/dock.js";
|
|
51
54
|
import { GateInputBar } from "./ui/gate-input.js";
|
|
52
55
|
import { HistoryPicker } from "./ui/history-picker.js";
|
|
@@ -408,7 +411,7 @@ export function apply(ctx, config) {
|
|
|
408
411
|
const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
|
|
409
412
|
const restore = createRestoreRegistry();
|
|
410
413
|
const terminal = new ProcessTerminal();
|
|
411
|
-
const tui = new WarningSafeTui(terminal);
|
|
414
|
+
const tui = new WarningSafeTui(terminal, { copySelection });
|
|
412
415
|
// A frame that cannot be drawn leaves the last good screen up, so the failure
|
|
413
416
|
// has to reach the transcript: otherwise the surface looks frozen and nothing
|
|
414
417
|
// on screen can say why.
|
|
@@ -431,6 +434,20 @@ export function apply(ctx, config) {
|
|
|
431
434
|
if (!handedOver)
|
|
432
435
|
terminal.write(text);
|
|
433
436
|
};
|
|
437
|
+
/**
|
|
438
|
+
* Put a copied selection on the clipboard as the words it selected.
|
|
439
|
+
*
|
|
440
|
+
* A terminal copies the screen, so a drag across a message takes the box the
|
|
441
|
+
* message was drawn in along with it. The rows the last frame drew are the
|
|
442
|
+
* account of that frame, so the copy is read back through them: the shape the
|
|
443
|
+
* surface added comes off, and nothing the reader wrote does.
|
|
444
|
+
*/
|
|
445
|
+
function copySelection(text) {
|
|
446
|
+
if (handedOver)
|
|
447
|
+
return Promise.resolve(false);
|
|
448
|
+
terminal.write(clipboardSequence(cleanCopied(text, view.copyRows())));
|
|
449
|
+
return Promise.resolve(true);
|
|
450
|
+
}
|
|
434
451
|
/** The session this surface drives: commands, approvals, and the bell belong to it. */
|
|
435
452
|
let activeSession = resolved.sessionId;
|
|
436
453
|
/** The session the transcript is showing, which can be one of its children. */
|
|
@@ -462,6 +479,17 @@ export function apply(ctx, config) {
|
|
|
462
479
|
});
|
|
463
480
|
let agent;
|
|
464
481
|
let turnOpen = false;
|
|
482
|
+
/**
|
|
483
|
+
* The staged undo cursor for the session this terminal drives.
|
|
484
|
+
*
|
|
485
|
+
* Prompts are never deleted: the cursor chooses how much of the live log the
|
|
486
|
+
* transcript shows. A cursor counts the turns of one log, so openAgent resets it.
|
|
487
|
+
*/
|
|
488
|
+
let undoState = NO_UNDO;
|
|
489
|
+
/** Event index the transcript must not fold past while turns are hidden. */
|
|
490
|
+
let stagedCut;
|
|
491
|
+
/** Whether an undo is settling an interrupt, so a second press cannot race it. */
|
|
492
|
+
let undoPending = false;
|
|
465
493
|
let turnStartedAt;
|
|
466
494
|
let exited = false;
|
|
467
495
|
/** Whether a session was really opened, which is what an exit hint can name. */
|
|
@@ -595,8 +623,9 @@ export function apply(ctx, config) {
|
|
|
595
623
|
herdr.unblock();
|
|
596
624
|
editor.disableSubmit = false;
|
|
597
625
|
// The question is answered or skipped, so the reader gets their prompt back
|
|
598
|
-
// in the bar they left it in.
|
|
626
|
+
// in the bar they left it in, with the menu the prompt bar offered.
|
|
599
627
|
promptBar.giveBack();
|
|
628
|
+
applyCompletion();
|
|
600
629
|
tui.setFocus(editor);
|
|
601
630
|
tui.requestRender();
|
|
602
631
|
};
|
|
@@ -791,19 +820,42 @@ export function apply(ctx, config) {
|
|
|
791
820
|
return undefined;
|
|
792
821
|
}));
|
|
793
822
|
const registry = () => ctx.get('commands');
|
|
823
|
+
/** One scan of the workspace, shared by both menus: the listing is read-only and cached. */
|
|
824
|
+
const fileIndex = createFileIndex(process.cwd());
|
|
825
|
+
/** The menu the prompt bar offers: the commands this session can run, and the workspace's files. */
|
|
826
|
+
let promptCompletion;
|
|
827
|
+
/** The menu an answer offers, which is the same menu without the commands. */
|
|
828
|
+
let answerCompletion;
|
|
829
|
+
/**
|
|
830
|
+
* Give the bar the menu its current role calls for.
|
|
831
|
+
*
|
|
832
|
+
* A question borrows the prompt bar's editor rather than drawing its own, so
|
|
833
|
+
* the provider has to follow the borrow: a slash command is a line this
|
|
834
|
+
* surface would run, and an answer is text the model reads, so the two roles
|
|
835
|
+
* must not share one menu.
|
|
836
|
+
*/
|
|
837
|
+
const applyCompletion = () => {
|
|
838
|
+
const chosen = promptBar.isBorrowed() ? answerCompletion : promptCompletion;
|
|
839
|
+
if (chosen !== undefined)
|
|
840
|
+
editor.setAutocompleteProvider(chosen);
|
|
841
|
+
};
|
|
794
842
|
/**
|
|
795
843
|
* Offer completion for whatever this session can run right now.
|
|
796
844
|
*
|
|
797
845
|
* The registry is agent-scoped and still empty while the agent starts, so the
|
|
798
846
|
* menu is rebuilt when the agent arrives and whenever a package registers
|
|
799
|
-
* another command.
|
|
847
|
+
* another command. Both providers are built here because the answer's is the
|
|
848
|
+
* prompt bar's own minus the commands, and a rebuild while a question is open
|
|
849
|
+
* must not leave the answer menu unreachable.
|
|
800
850
|
*/
|
|
801
851
|
const installCompletion = () => {
|
|
802
852
|
const current = agent?.agent;
|
|
803
853
|
const commands = registry();
|
|
804
854
|
if (current === undefined || commands === undefined)
|
|
805
855
|
return;
|
|
806
|
-
|
|
856
|
+
answerCompletion = createAnswerCompletionProvider(process.cwd(), fileIndex);
|
|
857
|
+
promptCompletion = createCompletionProvider(commands.list(current), process.cwd(), fileIndex);
|
|
858
|
+
applyCompletion();
|
|
807
859
|
};
|
|
808
860
|
/**
|
|
809
861
|
* Take the keyboard for a picker.
|
|
@@ -1093,12 +1145,19 @@ export function apply(ctx, config) {
|
|
|
1093
1145
|
* what tells the two apart.
|
|
1094
1146
|
*/
|
|
1095
1147
|
const foldCursor = new FoldCursor();
|
|
1096
|
-
/**
|
|
1148
|
+
/**
|
|
1149
|
+
* Fold one durable event into the view, unless a staged cursor hides it.
|
|
1150
|
+
*
|
|
1151
|
+
* Undo never deletes, so the log keeps the hidden suffix; this filter is the
|
|
1152
|
+
* one place the transcript and the log disagree about where the session ends.
|
|
1153
|
+
*/
|
|
1097
1154
|
const applyDurable = (session, event) => {
|
|
1155
|
+
if (stagedCut !== undefined && session === activeSession && typeof event.seq === 'number' && event.seq >= stagedCut)
|
|
1156
|
+
return;
|
|
1098
1157
|
if (foldCursor.accept(session, event))
|
|
1099
1158
|
applyEvent(event);
|
|
1100
1159
|
};
|
|
1101
|
-
const foldHistory = async (id) => {
|
|
1160
|
+
const foldHistory = async (id, through) => {
|
|
1102
1161
|
// Resolved before the fold so every card reads its tool through the scope
|
|
1103
1162
|
// that actually registered it; a stored session nobody runs has none.
|
|
1104
1163
|
presentScope = ctx.agents?.get(id);
|
|
@@ -1107,10 +1166,13 @@ export function apply(ctx, config) {
|
|
|
1107
1166
|
foldCursor.reset();
|
|
1108
1167
|
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
1109
1168
|
if (inMemory !== undefined) {
|
|
1110
|
-
|
|
1169
|
+
const events = through === undefined ? inMemory : inMemory.slice(0, through);
|
|
1170
|
+
for (const event of events)
|
|
1111
1171
|
applyDurable(id, event);
|
|
1112
|
-
return
|
|
1172
|
+
return events.length;
|
|
1113
1173
|
}
|
|
1174
|
+
// Only a live session can be staged: undo acts on the agent this terminal
|
|
1175
|
+
// drives, and a stored log has no cursor to hide a suffix from.
|
|
1114
1176
|
const history = createSessionHistory(ctx);
|
|
1115
1177
|
if (history === undefined)
|
|
1116
1178
|
return 0;
|
|
@@ -1167,6 +1229,192 @@ export function apply(ctx, config) {
|
|
|
1167
1229
|
const history = createSessionHistory(ctx);
|
|
1168
1230
|
return history === undefined ? [] : history.read(id);
|
|
1169
1231
|
};
|
|
1232
|
+
/** The closed turns of the session this terminal drives, newest last. */
|
|
1233
|
+
const currentTurns = async () => turnsOf(await sessionEvents(activeSession));
|
|
1234
|
+
/** Fold the transcript through the staged cut, or the whole log at the tip. */
|
|
1235
|
+
const redrawStaged = async () => {
|
|
1236
|
+
model.reset();
|
|
1237
|
+
work.reset();
|
|
1238
|
+
await foldHistory(activeSession, stagedCut);
|
|
1239
|
+
};
|
|
1240
|
+
/** Park queued prompts one per stash entry, newest first so popping replays queue order. */
|
|
1241
|
+
const parkQueued = async (queued) => {
|
|
1242
|
+
for (const text of [...queued].reverse())
|
|
1243
|
+
await stash?.stashEditor(text);
|
|
1244
|
+
};
|
|
1245
|
+
/** Resolver for the turn/end an undo is waiting on, set only while waiting. */
|
|
1246
|
+
let turnSettled;
|
|
1247
|
+
/** Wait for the interrupted turn to close, bounded so undo can give up honestly. */
|
|
1248
|
+
const waitForTurnEnd = (timeoutMs) => new Promise(resolve => {
|
|
1249
|
+
const timer = setTimeout(() => {
|
|
1250
|
+
turnSettled = undefined;
|
|
1251
|
+
resolve(false);
|
|
1252
|
+
}, timeoutMs);
|
|
1253
|
+
turnSettled = () => {
|
|
1254
|
+
clearTimeout(timer);
|
|
1255
|
+
turnSettled = undefined;
|
|
1256
|
+
resolve(true);
|
|
1257
|
+
};
|
|
1258
|
+
});
|
|
1259
|
+
/**
|
|
1260
|
+
* Stop the running turn and empty the inbox so the cut lands on a closed
|
|
1261
|
+
* turn/end; queued prompts are parked first because cancelling drops them.
|
|
1262
|
+
*
|
|
1263
|
+
* Returns undefined when the reader's words cannot be kept: a queue with no
|
|
1264
|
+
* stash to park it in, or a turn that will not close. Both leave the cursor
|
|
1265
|
+
* untouched, so the transcript keeps showing what the model actually saw; a
|
|
1266
|
+
* number is how many queued prompts were parked before the turn was stopped.
|
|
1267
|
+
*/
|
|
1268
|
+
const settleForUndo = async () => {
|
|
1269
|
+
const queued = queuedPrompts();
|
|
1270
|
+
if (!turnOpen && queued.length === 0)
|
|
1271
|
+
return 0;
|
|
1272
|
+
if (queued.length > 0 && stash === undefined) {
|
|
1273
|
+
model.notice('queued prompts have no stash to park in; undo cancelled');
|
|
1274
|
+
tui.requestRender();
|
|
1275
|
+
return undefined;
|
|
1276
|
+
}
|
|
1277
|
+
agent?.interrupt();
|
|
1278
|
+
if (queued.length > 0)
|
|
1279
|
+
await parkQueued(queued);
|
|
1280
|
+
if (turnOpen && !(await waitForTurnEnd(UNDO_TURN_SETTLE_MS))) {
|
|
1281
|
+
model.notice('could not stop the turn; undo cancelled');
|
|
1282
|
+
tui.requestRender();
|
|
1283
|
+
return undefined;
|
|
1284
|
+
}
|
|
1285
|
+
return queued.length;
|
|
1286
|
+
};
|
|
1287
|
+
const runUndoCommand = () => {
|
|
1288
|
+
if (undoPending)
|
|
1289
|
+
return;
|
|
1290
|
+
undoPending = true;
|
|
1291
|
+
void (async () => {
|
|
1292
|
+
if (viewedSession !== activeSession) {
|
|
1293
|
+
model.notice('undo works on the session this terminal drives · ctrl+b comes back');
|
|
1294
|
+
tui.requestRender();
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const turns = await currentTurns();
|
|
1298
|
+
if (undoState.hidden >= turns.length && !turnOpen) {
|
|
1299
|
+
model.notice('nothing to undo');
|
|
1300
|
+
tui.requestRender();
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
const draft = editor.getExpandedText();
|
|
1304
|
+
// Text this state itself restored is not the reader's draft, so undoing
|
|
1305
|
+
// again must not park it and end up with two copies.
|
|
1306
|
+
const holdsDraft = draft !== '' && draft !== undoState.lastRestored;
|
|
1307
|
+
if (holdsDraft && stash === undefined) {
|
|
1308
|
+
model.notice('the bar holds a draft and there is no stash to park it in; undo cancelled');
|
|
1309
|
+
tui.requestRender();
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
const parked = await settleForUndo();
|
|
1313
|
+
if (parked === undefined)
|
|
1314
|
+
return;
|
|
1315
|
+
const settled = await currentTurns();
|
|
1316
|
+
const next = undoStep(undoState, settled);
|
|
1317
|
+
if (next === undefined) {
|
|
1318
|
+
model.notice('nothing to undo');
|
|
1319
|
+
tui.requestRender();
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (holdsDraft) {
|
|
1323
|
+
await stash?.stashEditor(draft);
|
|
1324
|
+
model.notice('the draft in the bar was parked in the stash');
|
|
1325
|
+
}
|
|
1326
|
+
undoState = next;
|
|
1327
|
+
stagedCut = hiddenTail(next, settled)?.seedCount;
|
|
1328
|
+
await redrawStaged();
|
|
1329
|
+
editor.setText(next.lastRestored);
|
|
1330
|
+
// The parking count rides the undo notice: a notice of its own would be
|
|
1331
|
+
// replaced before the frame could show it.
|
|
1332
|
+
const parkedNote = parked === 0 ? '' : ` · ${parked} queued prompt${parked === 1 ? '' : 's'} parked in the stash`;
|
|
1333
|
+
model.notice(`undo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden${parkedNote} · prefix r redo`);
|
|
1334
|
+
tui.requestRender();
|
|
1335
|
+
})()
|
|
1336
|
+
.catch((error) => {
|
|
1337
|
+
model.notice(`undo failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
+
tui.requestRender();
|
|
1339
|
+
})
|
|
1340
|
+
.finally(() => {
|
|
1341
|
+
undoPending = false;
|
|
1342
|
+
});
|
|
1343
|
+
};
|
|
1344
|
+
const runRedoCommand = () => {
|
|
1345
|
+
void (async () => {
|
|
1346
|
+
if (viewedSession !== activeSession) {
|
|
1347
|
+
model.notice('redo works on the session this terminal drives · ctrl+b comes back');
|
|
1348
|
+
tui.requestRender();
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const turns = await currentTurns();
|
|
1352
|
+
const before = editor.getExpandedText();
|
|
1353
|
+
const restored = undoState.lastRestored;
|
|
1354
|
+
const next = redoStep(undoState, turns);
|
|
1355
|
+
if (next === undefined) {
|
|
1356
|
+
model.notice('nothing to redo');
|
|
1357
|
+
tui.requestRender();
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
undoState = next;
|
|
1361
|
+
stagedCut = hiddenTail(next, turns)?.seedCount;
|
|
1362
|
+
await redrawStaged();
|
|
1363
|
+
// A composer the reader edited is theirs; only text this state wrote is replaced.
|
|
1364
|
+
if (before === restored)
|
|
1365
|
+
editor.setText(next.lastRestored);
|
|
1366
|
+
model.notice(next.hidden === 0
|
|
1367
|
+
? 'redo · back at the newest prompt'
|
|
1368
|
+
: `redo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden`);
|
|
1369
|
+
tui.requestRender();
|
|
1370
|
+
})().catch((error) => {
|
|
1371
|
+
model.notice(`redo failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1372
|
+
tui.requestRender();
|
|
1373
|
+
});
|
|
1374
|
+
};
|
|
1375
|
+
/**
|
|
1376
|
+
* Send the reader's prompt, branching first when the transcript is staged.
|
|
1377
|
+
*
|
|
1378
|
+
* Context is derived from the log, so a send while turns are hidden has to
|
|
1379
|
+
* continue in a child seeded with the visible prefix; submitting in place would
|
|
1380
|
+
* show the model the prompts the reader undid. Every send passes through here so
|
|
1381
|
+
* the branch has one commit point, not one per caller.
|
|
1382
|
+
*/
|
|
1383
|
+
const commitStagedSend = async (text) => {
|
|
1384
|
+
try {
|
|
1385
|
+
const previous = agent;
|
|
1386
|
+
if (previous === undefined) {
|
|
1387
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
1388
|
+
tui.requestRender();
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (undoState.hidden === 0) {
|
|
1392
|
+
previous.submit(text);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const source = activeSession;
|
|
1396
|
+
const events = await sessionEvents(source);
|
|
1397
|
+
const turns = turnsOf(events);
|
|
1398
|
+
const tail = hiddenTail(undoState, turns);
|
|
1399
|
+
const seed = tail === undefined ? [] : events.slice(0, tail.seedCount);
|
|
1400
|
+
const childId = SessionId(`tui-session-${randomUUID()}`);
|
|
1401
|
+
agent = undefined;
|
|
1402
|
+
turnOpen = false;
|
|
1403
|
+
turnStartedAt = undefined;
|
|
1404
|
+
presentScope = undefined;
|
|
1405
|
+
model.reset();
|
|
1406
|
+
work.reset();
|
|
1407
|
+
roster.reset();
|
|
1408
|
+
await previous.dispose();
|
|
1409
|
+
const opened = await openAgent(childId, false, seed.length === 0 ? undefined : { from: source, events: seed });
|
|
1410
|
+
model.notice(`continuing in a new branch · ${source} keeps the undone turns`);
|
|
1411
|
+
opened.submit(text);
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
model.notice(`could not send: ${error instanceof Error ? error.message : String(error)}`);
|
|
1415
|
+
tui.requestRender();
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1170
1418
|
/**
|
|
1171
1419
|
* The mode one agent joins.
|
|
1172
1420
|
*
|
|
@@ -1232,9 +1480,19 @@ export function apply(ctx, config) {
|
|
|
1232
1480
|
activeSession = id;
|
|
1233
1481
|
viewedSession = id;
|
|
1234
1482
|
agent = handle;
|
|
1483
|
+
// A cursor counts the turns of one log; the session just opened has its own.
|
|
1484
|
+
undoState = resetUndo(id);
|
|
1485
|
+
stagedCut = undefined;
|
|
1235
1486
|
// The bank follows the session, so the footer stops counting the drafts of
|
|
1236
1487
|
// the session just left and the next command reads this session's file.
|
|
1237
1488
|
void stash?.open();
|
|
1489
|
+
// agent/status is emitted on transitions only, so a driver that was
|
|
1490
|
+
// already running when this surface attached — a resume that wakes
|
|
1491
|
+
// straight away — would otherwise stay unreported until it stops. The
|
|
1492
|
+
// status is read once here and settled before the session identity, so the
|
|
1493
|
+
// report that carries the new session id states the driver's real phase
|
|
1494
|
+
// rather than the one the previous session left behind.
|
|
1495
|
+
herdr.driver(handle.agent.status);
|
|
1238
1496
|
// The agent's own id is reported rather than the requested one: a resume can
|
|
1239
1497
|
// be answered by the session the log actually holds.
|
|
1240
1498
|
herdr.session({
|
|
@@ -1262,6 +1520,9 @@ export function apply(ctx, config) {
|
|
|
1262
1520
|
installCompletion();
|
|
1263
1521
|
model.notice(`session ${handle.sessionId}${resume ? ' (resumed)' : ''}`);
|
|
1264
1522
|
tui.requestRender();
|
|
1523
|
+
// Returned so a caller that replaced the handle can send through the new
|
|
1524
|
+
// one without a fresh read of state TypeScript can no longer widen.
|
|
1525
|
+
return handle;
|
|
1265
1526
|
};
|
|
1266
1527
|
/** Move the surface to another stored session without leaving the terminal. */
|
|
1267
1528
|
const switchSession = async (id) => {
|
|
@@ -1271,6 +1532,9 @@ export function apply(ctx, config) {
|
|
|
1271
1532
|
// during the transition can read a torn-down world.
|
|
1272
1533
|
presentScope = undefined;
|
|
1273
1534
|
turnOpen = false;
|
|
1535
|
+
// The outgoing turn's clock dies with its agent; leaving it set would time
|
|
1536
|
+
// the session being joined by work it never ran.
|
|
1537
|
+
turnStartedAt = undefined;
|
|
1274
1538
|
model.reset();
|
|
1275
1539
|
work.reset();
|
|
1276
1540
|
roster.reset();
|
|
@@ -1431,6 +1695,48 @@ export function apply(ctx, config) {
|
|
|
1431
1695
|
tui.requestRender();
|
|
1432
1696
|
});
|
|
1433
1697
|
};
|
|
1698
|
+
/**
|
|
1699
|
+
* Compose this session's agent again without leaving the conversation.
|
|
1700
|
+
*
|
|
1701
|
+
* A preset's standing mount only re-reads its composition file for an agent
|
|
1702
|
+
* that joins after the file changed, so an edited preset, skill, or prompt
|
|
1703
|
+
* file reaches a running session only by joining anew. The durable log is
|
|
1704
|
+
* replayed afterwards, so the reader keeps the conversation they were reading.
|
|
1705
|
+
*/
|
|
1706
|
+
const runReloadCommand = () => {
|
|
1707
|
+
// A failed reload leaves no agent behind, so the command has to be usable
|
|
1708
|
+
// again: the retry is what makes a broken composition file recoverable,
|
|
1709
|
+
// while a surface that has opened no session yet is still starting.
|
|
1710
|
+
if (agent === undefined && !sessionOpened) {
|
|
1711
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
1712
|
+
tui.requestRender();
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const queued = queuedPrompts();
|
|
1716
|
+
// Work the reader would lose is a decision, and the interrupt key already
|
|
1717
|
+
// owns that decision: it stops the turn and hands queued words back to the
|
|
1718
|
+
// bar. Reopening the session drops the inbox, so a reload that would take
|
|
1719
|
+
// those words asks for the key instead of asking a question of its own.
|
|
1720
|
+
if (turnOpen || queued.length > 0) {
|
|
1721
|
+
model.notice(queued.length > 0
|
|
1722
|
+
? `${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} would be dropped — ctrl+c hands them back to the bar, then /reload`
|
|
1723
|
+
: 'a turn is running — ctrl+c interrupts it first (delivered text is kept), then /reload');
|
|
1724
|
+
tui.requestRender();
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const id = activeSession;
|
|
1728
|
+
void (async () => {
|
|
1729
|
+
// The same transition a session switch takes, aimed at the session
|
|
1730
|
+
// already open: dispose, then join its preset generation anew.
|
|
1731
|
+
await switchSession(id);
|
|
1732
|
+
model.notice("reloaded this session's composition; the transcript was replayed");
|
|
1733
|
+
tui.requestRender();
|
|
1734
|
+
})().catch((error) => {
|
|
1735
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1736
|
+
model.notice(`could not reload: ${reason} — fix the composition and /reload again`);
|
|
1737
|
+
tui.requestRender();
|
|
1738
|
+
});
|
|
1739
|
+
};
|
|
1434
1740
|
/** Show the todo list the agent has been keeping. */
|
|
1435
1741
|
const runTodoCommand = () => {
|
|
1436
1742
|
model.notice(describeTodos(work.state().todos));
|
|
@@ -1941,6 +2247,9 @@ export function apply(ctx, config) {
|
|
|
1941
2247
|
case 'new':
|
|
1942
2248
|
runNewCommand(submission.title);
|
|
1943
2249
|
return;
|
|
2250
|
+
case 'reload':
|
|
2251
|
+
runReloadCommand();
|
|
2252
|
+
return;
|
|
1944
2253
|
case 'todo':
|
|
1945
2254
|
runTodoCommand();
|
|
1946
2255
|
return;
|
|
@@ -2084,6 +2393,12 @@ export function apply(ctx, config) {
|
|
|
2084
2393
|
work.reset();
|
|
2085
2394
|
tui.requestRender();
|
|
2086
2395
|
return;
|
|
2396
|
+
case 'undo':
|
|
2397
|
+
runUndoCommand();
|
|
2398
|
+
return;
|
|
2399
|
+
case 'redo':
|
|
2400
|
+
runRedoCommand();
|
|
2401
|
+
return;
|
|
2087
2402
|
case 'help':
|
|
2088
2403
|
model.notice(helpText());
|
|
2089
2404
|
tui.requestRender();
|
|
@@ -2113,7 +2428,7 @@ export function apply(ctx, config) {
|
|
|
2113
2428
|
agent.steer(submission.text);
|
|
2114
2429
|
else {
|
|
2115
2430
|
adoptDefaultRoute();
|
|
2116
|
-
|
|
2431
|
+
void commitStagedSend(submission.text);
|
|
2117
2432
|
}
|
|
2118
2433
|
}
|
|
2119
2434
|
};
|
|
@@ -2136,14 +2451,14 @@ export function apply(ctx, config) {
|
|
|
2136
2451
|
turnOpen = true;
|
|
2137
2452
|
turnStartedAt = Date.now();
|
|
2138
2453
|
writeTerminal(windowTitle(process.cwd(), 'working'));
|
|
2139
|
-
herdr.working();
|
|
2140
2454
|
}
|
|
2141
2455
|
if (event.type === 'turn/end') {
|
|
2142
2456
|
const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
|
|
2143
2457
|
turnOpen = false;
|
|
2144
2458
|
turnStartedAt = undefined;
|
|
2459
|
+
// An undo may be parked waiting for exactly this boundary.
|
|
2460
|
+
turnSettled?.();
|
|
2145
2461
|
writeTerminal(windowTitle(process.cwd(), 'ready'));
|
|
2146
|
-
herdr.idle();
|
|
2147
2462
|
if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
|
|
2148
2463
|
writeTerminal(BELL);
|
|
2149
2464
|
// A job the turn started may have settled while the reader was watching
|
|
@@ -2168,6 +2483,20 @@ export function apply(ctx, config) {
|
|
|
2168
2483
|
* harness cannot break compilation of this surface.
|
|
2169
2484
|
*/
|
|
2170
2485
|
const listenFor = (name, handler) => ctx.on(name, handler);
|
|
2486
|
+
// Herdr hears the driver rather than each turn: a run that chains turns
|
|
2487
|
+
// through a pending inbox is one stretch of work, and a turn boundary inside
|
|
2488
|
+
// it would read as done between two turns of an agent that is still working.
|
|
2489
|
+
// The turn listeners above keep the title and the bell, which are about the
|
|
2490
|
+
// reader's own conversation.
|
|
2491
|
+
disposers.push(listenFor('agent/status', payload => {
|
|
2492
|
+
// Read against the driven session at delivery time: the reader can switch
|
|
2493
|
+
// sessions between two transitions, and the row must follow the one this
|
|
2494
|
+
// terminal now drives.
|
|
2495
|
+
const status = driverReportFor(payload, activeSession);
|
|
2496
|
+
if (status === undefined)
|
|
2497
|
+
return;
|
|
2498
|
+
herdr.driver(status);
|
|
2499
|
+
}));
|
|
2171
2500
|
disposers.push(listenFor('subagent/start', info => {
|
|
2172
2501
|
roster.start(info);
|
|
2173
2502
|
const record = asRecord(info);
|
|
@@ -2209,7 +2538,13 @@ export function apply(ctx, config) {
|
|
|
2209
2538
|
if (questions.length === 0)
|
|
2210
2539
|
return next();
|
|
2211
2540
|
return new Promise(resolve => {
|
|
2212
|
-
const gate = promptBar.borrow(() =>
|
|
2541
|
+
const gate = promptBar.borrow(() => {
|
|
2542
|
+
// The bar is an answer's for as long as the gate holds it, so the menu
|
|
2543
|
+
// it once offered commands through is replaced before a key can reach it.
|
|
2544
|
+
const built = new QuestionGate(questions, editor, () => keymap);
|
|
2545
|
+
applyCompletion();
|
|
2546
|
+
return built;
|
|
2547
|
+
});
|
|
2213
2548
|
// The seam takes mutable selection arrays and an optional custom field, so
|
|
2214
2549
|
// the read-only gate answer is copied into that exact shape here.
|
|
2215
2550
|
const settle = (answers) => {
|