dsh-code 0.1.0 → 0.2.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/lib/index.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { basename, join } from "node:path";
4
- import { createElement, useState, useSyncExternalStore } from "react";
4
+ import { createElement, useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import z from "@deepseek-ai/schemastery";
5
6
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
6
7
  import { assertNever, boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
7
8
  import { SessionId } from "@deepseek-ai/dsh-session";
8
9
  import { Box, Text, render, useInput } from "ink";
9
10
  import chalk from "chalk";
11
+ import { isUserInvocable } from "@deepseek-ai/dsh-skill";
10
12
  //#region src/theme.ts
11
13
  /**
12
14
  * Terminal color tokens for the dsh TUI, mapped from the product design
@@ -76,6 +78,10 @@ function dim(text) {
76
78
  function error(text) {
77
79
  return chalk.rgb(...TUI_RGB.error)(text);
78
80
  }
81
+ /** Paint warnings. */
82
+ function warn(text) {
83
+ return chalk.rgb(...TUI_RGB.warn)(text);
84
+ }
79
85
  //#endregion
80
86
  //#region src/whale-glyph.ts
81
87
  /** Half-block whale glyph rows; render with the brand color. */
@@ -152,19 +158,45 @@ function buildStatusGroups(facts, stats) {
152
158
  return groups;
153
159
  }
154
160
  //#endregion
161
+ //#region src/render/text.ts
162
+ /**
163
+ * Display-boundary sanitization for externally sourced text (model output,
164
+ * tool payloads, skill descriptions). Control characters — including ANSI
165
+ * CSI/OSC escape sequences — would otherwise pass through Ink into the
166
+ * terminal, letting output rewrite the screen or inject prompts. Newlines
167
+ * and tabs survive; everything else in C0/C1 plus DEL becomes a visible
168
+ * `\xNN` escape.
169
+ *
170
+ * @module @deepseek-ai/dsh-code/render/text
171
+ */
172
+ /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
173
+ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
174
+ /**
175
+ * Escape control characters so externally sourced text cannot drive the
176
+ * terminal.
177
+ * @param text - raw text from a session event, tool payload, or catalog.
178
+ * @returns text with every control character (except `\n`, `\t`) rendered
179
+ * as a literal `\xNN` escape.
180
+ */
181
+ function displayText(text) {
182
+ return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
183
+ }
184
+ //#endregion
155
185
  //#region src/app.ts
156
186
  /**
157
187
  * The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
158
- * transcript, the streaming line, local notices, and the input box. All state
159
- * arrives through the transcript store (derived from the durable session log)
160
- * plus local input state; the app owns no session mutation of its own.
188
+ * transcript, the todo panel, the streaming line, the approval bar, the model
189
+ * panel, local notices, and the input box with history and slash-command
190
+ * completion. All state arrives through the transcript store (derived from
191
+ * the durable session log) plus local input state; the app owns no session
192
+ * mutation of its own.
161
193
  *
162
194
  * Element construction uses `createElement` (not JSX): the `dsh` source launch
163
195
  * compiles this file through tsx's ESM-only hook, which does not adopt this
164
196
  * package's `jsx: react-jsx` compiler option, and the classic JSX runtime
165
197
  * would demand a React global.
166
198
  *
167
- * @module @deepseek-ai/dsh-tui/app
199
+ * @module @deepseek-ai/dsh-code/app
168
200
  */
169
201
  /** Ink `color` string for one palette triple. */
170
202
  function inkColor(triple) {
@@ -173,18 +205,22 @@ function inkColor(triple) {
173
205
  /** One settled transcript row. */
174
206
  function EntryLine({ entry }) {
175
207
  switch (entry.kind) {
176
- case "user": return createElement(Text, null, brand("❯ "), entry.text);
177
- case "assistant": return createElement(Text, null, entry.text);
208
+ case "user": return createElement(Text, null, brand("❯ "), displayText(entry.text));
209
+ case "assistant": return createElement(Text, null, displayText(entry.text));
178
210
  case "tool": {
179
211
  const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
180
- return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(entry.summary)}`);
212
+ return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(displayText(entry.summary))}`);
181
213
  }
182
- case "error": return createElement(Text, null, error(entry.text));
214
+ case "command": {
215
+ const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
216
+ return createElement(Text, null, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`, entry.summary === "" ? "" : ` ${dim(displayText(entry.summary))}`);
217
+ }
218
+ case "error": return createElement(Text, null, error(displayText(entry.text)));
183
219
  default: return assertNever(entry, "transcript entry kind");
184
220
  }
185
221
  }
186
222
  /** The whale wordmark header in DeepSeek blue, hugging its content width. */
187
- function Header() {
223
+ function Header({ resumed }) {
188
224
  return createElement(Box, {
189
225
  flexDirection: "row",
190
226
  gap: 1,
@@ -204,7 +240,32 @@ function Header() {
204
240
  }, createElement(Text, {
205
241
  color: inkColor(TUI_RGB.brand),
206
242
  bold: true
207
- }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, "/help commands · Ctrl+C quit")));
243
+ }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit")));
244
+ }
245
+ /** Todo status glyph: web TodoPanel's three-state marker. */
246
+ function todoMark(status) {
247
+ return status === "completed" ? "✓" : status === "in_progress" ? "●" : "○";
248
+ }
249
+ /** Inline todo list (web TodoPanel's compact terminal form). */
250
+ function TodoPanel({ todos }) {
251
+ if (todos.length === 0) return void 0;
252
+ const completed = todos.filter((todo) => todo.status === "completed").length;
253
+ const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
254
+ const pending = todos.length - completed - inProgress;
255
+ return createElement(Box, {
256
+ flexDirection: "column",
257
+ paddingX: 1,
258
+ borderStyle: "round",
259
+ borderColor: inkColor(TUI_RGB.brandDeep),
260
+ alignSelf: "flex-start",
261
+ marginLeft: 1
262
+ }, createElement(Text, {
263
+ color: inkColor(TUI_RGB.brand),
264
+ bold: true
265
+ }, `todos ${completed}/${todos.length}`, createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`)), ...todos.map((todo, index) => createElement(Text, {
266
+ key: index,
267
+ color: todo.status === "completed" ? inkColor(TUI_RGB.success) : todo.status === "in_progress" ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
268
+ }, `${todoMark(todo.status)} ${displayText(todo.content)}`)));
208
269
  }
209
270
  /**
210
271
  * The footer status line: Claude-Code-style identity facts (model, working
@@ -221,75 +282,468 @@ function StatusLine({ facts, stats, busy }) {
221
282
  });
222
283
  return createElement(Box, { paddingX: 1 }, ...children);
223
284
  }
224
- /** The prompt box: slash commands handled locally, other text submitted. */
225
- function Input({ busy, onSubmit, onQuit }) {
285
+ /** The y/n approval bar rendered while an approval ask is pending. */
286
+ function ApprovalBar({ approval }) {
287
+ const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot);
288
+ if (snapshot.pending === void 0) return void 0;
289
+ const { pending, answered } = snapshot;
290
+ return createElement(Box, {
291
+ flexDirection: "column",
292
+ paddingX: 1,
293
+ borderStyle: "round",
294
+ borderColor: inkColor(TUI_RGB.warn),
295
+ alignSelf: "flex-start",
296
+ marginLeft: 1
297
+ }, createElement(Text, {
298
+ color: inkColor(TUI_RGB.warn),
299
+ bold: true
300
+ }, "⏸ waiting for approval"), createElement(Text, null, warn(displayText(pending.headline))), pending.command === "" ? void 0 : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)), answered ? createElement(Text, { dimColor: true }, " submitted…") : createElement(Text, { dimColor: true }, dim(" y allow once · n reject")));
301
+ }
302
+ /** The /model panel: a scrolling list over the advisory model directory. */
303
+ function ModelPanel({ directory, error, onSelect, onClose }) {
304
+ const [cursor, setCursor] = useState(0);
305
+ useInput((input, key) => {
306
+ if (key.escape || input === "q") {
307
+ onClose();
308
+ return;
309
+ }
310
+ const rows = directory?.rows ?? [];
311
+ if (key.upArrow) {
312
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
313
+ return;
314
+ }
315
+ if (key.downArrow) {
316
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
317
+ return;
318
+ }
319
+ if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
320
+ });
321
+ const rows = directory?.rows ?? [];
322
+ const window = 8;
323
+ const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window));
324
+ const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window);
325
+ return createElement(Box, {
326
+ flexDirection: "column",
327
+ paddingX: 1,
328
+ borderStyle: "round",
329
+ borderColor: inkColor(TUI_RGB.brand),
330
+ alignSelf: "flex-start",
331
+ marginLeft: 1
332
+ }, createElement(Text, {
333
+ color: inkColor(TUI_RGB.brand),
334
+ bold: true
335
+ }, "/model — select the model for the next step"), directory === void 0 && error === void 0 ? createElement(Text, { dimColor: true }, " loading models…") : void 0, error !== void 0 ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`) : void 0, ...visible.map((row) => {
336
+ const index = rows.indexOf(row);
337
+ const label = displayText(`${row.providerName} · ${row.modelName}`);
338
+ return createElement(Text, {
339
+ key: `${row.provider}/${row.model}`,
340
+ color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
341
+ }, `${index === cursor ? "❯ " : " "}${label}`);
342
+ }), createElement(Text, { dimColor: true }, dim(" ↑↓ move · enter select · esc close")));
343
+ }
344
+ /**
345
+ * Resolve completion candidates for the current input: TUI-local commands,
346
+ * the live registry descriptors, and user-invocable skills, filtered by the
347
+ * typed prefix. Command names win collisions (the dispatch tries the
348
+ * registry first and only then falls through to the skill gesture).
349
+ */
350
+ function completionCandidates(value, descriptors, skills) {
351
+ if (!value.startsWith("/")) return [];
352
+ const prefix = value.slice(1).split(" ")[0] ?? "";
353
+ const local = [
354
+ {
355
+ label: "/help",
356
+ description: "show commands",
357
+ origin: "command"
358
+ },
359
+ {
360
+ label: "/model",
361
+ description: "switch the model",
362
+ origin: "command"
363
+ },
364
+ {
365
+ label: "/clear",
366
+ description: "clear the screen",
367
+ origin: "command"
368
+ },
369
+ {
370
+ label: "/quit",
371
+ description: "exit",
372
+ origin: "command"
373
+ }
374
+ ];
375
+ const registry = descriptors.map((descriptor) => ({
376
+ label: `/${descriptor.name}`,
377
+ description: descriptor.description,
378
+ origin: "command"
379
+ }));
380
+ const taken = new Set([...local, ...registry].map((candidate) => candidate.label.slice(1)));
381
+ const skillRows = skills.filter((skill) => !taken.has(skill.name)).map((skill) => ({
382
+ label: `/${skill.name}`,
383
+ description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
384
+ origin: "skill"
385
+ }));
386
+ const all = [
387
+ ...local,
388
+ ...registry,
389
+ ...skillRows
390
+ ];
391
+ if (prefix === "") return all.slice(0, 10);
392
+ return all.filter((candidate) => candidate.label.slice(1).startsWith(prefix)).slice(0, 10);
393
+ }
394
+ /**
395
+ * The prompt box: TUI-local slash commands handled locally, other lines
396
+ * dispatched; input editing keeps a cursor with history and completion.
397
+ */
398
+ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }) {
226
399
  const [value, setValue] = useState("");
227
- const [notices, setNotices] = useState([]);
400
+ const [cursor, setCursor] = useState(0);
401
+ const history = useRef([]);
402
+ const historyIndex = useRef(null);
403
+ const draft = useRef("");
404
+ const [completionIndex, setCompletionIndex] = useState(0);
405
+ const candidates = completionCandidates(value, descriptors, skills);
406
+ const completionActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
228
407
  useInput((input, key) => {
229
- if (key.ctrl && (input === "c" || input === "d")) {
230
- onQuit();
408
+ if (key.ctrl && input === "c") {
409
+ if (busy) interrupt();
410
+ else if (value !== "") {
411
+ setValue("");
412
+ setCursor(0);
413
+ setCompletionIndex(0);
414
+ } else quit();
415
+ return;
416
+ }
417
+ if (key.ctrl && input === "d") {
418
+ if (busy) notify("cancel the running turn before exiting (Esc or Ctrl+C)");
419
+ else quit();
420
+ return;
421
+ }
422
+ if (key.escape) {
423
+ if (busy) interrupt();
231
424
  return;
232
425
  }
233
426
  if (key.return) {
427
+ if (key.meta || key.ctrl && input === "j") {
428
+ setValue(value.slice(0, cursor) + "\n" + value.slice(cursor));
429
+ setCursor(cursor + 1);
430
+ return;
431
+ }
234
432
  const text = value.trim();
235
433
  setValue("");
434
+ setCursor(0);
435
+ setCompletionIndex(0);
236
436
  if (text === "") return;
437
+ history.current = [...history.current, text];
438
+ historyIndex.current = null;
237
439
  if (text === "/quit") {
238
- onQuit();
440
+ quit();
239
441
  return;
240
442
  }
241
443
  if (text === "/help") {
242
- setNotices([...notices, "/help show commands · /clear clear the screen · /quit exit"]);
444
+ notify("/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn");
243
445
  return;
244
446
  }
245
447
  if (text === "/clear") {
246
- setNotices([]);
247
448
  console.clear();
248
449
  return;
249
450
  }
250
- if (busy) {
251
- setNotices([...notices, "the agent is working — wait for the turn to finish"]);
451
+ if (text === "/model" || text.startsWith("/model ")) {
452
+ openModel();
453
+ return;
454
+ }
455
+ if (busy && !text.startsWith("/")) {
456
+ steer(text);
252
457
  return;
253
458
  }
254
- onSubmit(text);
459
+ dispatch(text);
460
+ return;
461
+ }
462
+ if (completionActive && key.upArrow) {
463
+ setCompletionIndex((index) => (index + candidates.length - 1) % candidates.length);
464
+ return;
465
+ }
466
+ if (completionActive && key.downArrow) {
467
+ setCompletionIndex((index) => (index + 1) % candidates.length);
468
+ return;
469
+ }
470
+ if (key.upArrow) {
471
+ const entries = history.current;
472
+ if (entries.length === 0) return;
473
+ const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1);
474
+ if (historyIndex.current === null) draft.current = value;
475
+ historyIndex.current = next;
476
+ setValue(entries[next] ?? "");
477
+ setCursor((entries[next] ?? "").length);
478
+ return;
479
+ }
480
+ if (key.downArrow) {
481
+ const entries = history.current;
482
+ if (historyIndex.current === null) return;
483
+ const next = historyIndex.current + 1;
484
+ if (next >= entries.length) {
485
+ historyIndex.current = null;
486
+ setValue(draft.current);
487
+ setCursor(draft.current.length);
488
+ return;
489
+ }
490
+ historyIndex.current = next;
491
+ setValue(entries[next] ?? "");
492
+ setCursor((entries[next] ?? "").length);
493
+ return;
494
+ }
495
+ if (key.tab && completionActive) {
496
+ const candidate = candidates[completionIndex % candidates.length];
497
+ if (candidate !== void 0) {
498
+ setValue(`${candidate.label} `);
499
+ setCursor(candidate.label.length + 1);
500
+ setCompletionIndex(0);
501
+ }
255
502
  return;
256
503
  }
257
504
  if (key.backspace || key.delete) {
258
- setValue(value.slice(0, -1));
505
+ if (cursor > 0) {
506
+ setValue(value.slice(0, cursor - 1) + value.slice(cursor));
507
+ setCursor(cursor - 1);
508
+ setCompletionIndex(0);
509
+ }
510
+ return;
511
+ }
512
+ if (key.leftArrow) {
513
+ setCursor(Math.max(0, cursor - 1));
514
+ return;
515
+ }
516
+ if (key.rightArrow) {
517
+ setCursor(Math.min(value.length, cursor + 1));
259
518
  return;
260
519
  }
261
- if (input !== "") setValue(value + input);
520
+ if (key.ctrl && input === "u") {
521
+ setValue("");
522
+ setCursor(0);
523
+ return;
524
+ }
525
+ if (key.ctrl && input === "a") {
526
+ setCursor(0);
527
+ return;
528
+ }
529
+ if (key.ctrl && input === "e") {
530
+ setCursor(value.length);
531
+ return;
532
+ }
533
+ if (input !== "" && !key.ctrl && !key.meta) {
534
+ setValue(value.slice(0, cursor) + input + value.slice(cursor));
535
+ setCursor(cursor + input.length);
536
+ setCompletionIndex(0);
537
+ }
262
538
  });
263
- return createElement(Box, { flexDirection: "column" }, ...notices.map((notice, index) => createElement(Text, {
264
- key: index,
265
- dimColor: true
266
- }, notice)), createElement(Box, null, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), createElement(Text, null, value)));
539
+ return createElement(Box, { flexDirection: "column" }, completionActive && !busy ? createElement(Box, {
540
+ flexDirection: "column",
541
+ marginLeft: 1
542
+ }, ...candidates.map((candidate, index) => createElement(Text, {
543
+ key: candidate.label,
544
+ color: index === completionIndex % candidates.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
545
+ }, `${index === completionIndex % candidates.length ? "❯ " : " "}${candidate.label} ${dim(displayText(candidate.description))}`)), createElement(Text, { dimColor: true }, dim(" ↑↓ choose · tab complete"))) : void 0, busy && value === "" ? createElement(Text, { dimColor: true }, dim(" enter steers the running turn · esc or ctrl+c cancels")) : void 0, createElement(Box, null, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), createElement(Text, null, value.slice(0, cursor)), createElement(Text, { inverse: true }, value.slice(cursor, cursor + 1) === "" ? " " : value.slice(cursor, cursor + 1)), createElement(Text, null, value.slice(cursor + 1))));
267
546
  }
268
547
  /** The whole terminal app; state arrives via the store, output via Ink. */
269
- function App({ store, model, cwd, branch, sessionId, onSubmit, onQuit }) {
270
- const view = useSyncExternalStore(store.subscribe, store.getView);
271
- return createElement(Box, { flexDirection: "column" }, createElement(Header), createElement(Box, {
548
+ function App(props) {
549
+ const view = useSyncExternalStore(props.store.subscribe, props.store.getView);
550
+ const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors);
551
+ const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows);
552
+ const [modelLabel, setModelLabel] = useState(props.model);
553
+ const [modelOpen, setModelOpen] = useState(false);
554
+ const [directory, setDirectory] = useState(void 0);
555
+ const [modelError, setModelError] = useState(void 0);
556
+ const [notices, setNotices] = useState([]);
557
+ const notify = (text) => {
558
+ setNotices((current) => [...current, text]);
559
+ };
560
+ useEffect(() => {
561
+ props.onBridgeReady({ notify });
562
+ }, []);
563
+ useEffect(() => {
564
+ if (!modelOpen || directory !== void 0) return;
565
+ let cancelled = false;
566
+ setModelError(void 0);
567
+ props.loadModels().then((loaded) => {
568
+ if (!cancelled) setDirectory(loaded);
569
+ }, (error) => {
570
+ if (!cancelled) setModelError(error instanceof Error ? error.message : String(error));
571
+ });
572
+ return () => {
573
+ cancelled = true;
574
+ };
575
+ }, [modelOpen]);
576
+ const busy = view.busy;
577
+ return createElement(Box, { flexDirection: "column" }, createElement(Header, { resumed: props.resumed }), createElement(Box, {
272
578
  flexDirection: "column",
273
579
  paddingX: 1
274
580
  }, ...view.entries.map((entry, index) => createElement(EntryLine, {
275
581
  key: index,
276
582
  entry
277
- })), view.streaming !== "" ? createElement(Text, null, view.streaming) : void 0, view.busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "thinking…") : void 0), createElement(Input, {
278
- busy: view.busy,
279
- onSubmit,
280
- onQuit
583
+ })), view.streaming !== "" ? createElement(Text, null, displayText(view.streaming)) : void 0, busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "thinking…") : void 0), createElement(TodoPanel, { todos: view.todos }), createElement(ApprovalBar, { approval: props.approval }), modelOpen ? createElement(ModelPanel, {
584
+ directory,
585
+ error: modelError,
586
+ onSelect: (row) => {
587
+ setModelLabel(props.selectModel(row));
588
+ notify(`model → next step uses ${row.provider}/${row.model}`);
589
+ setModelOpen(false);
590
+ },
591
+ onClose: () => {
592
+ setModelOpen(false);
593
+ }
594
+ }) : void 0, createElement(Box, { flexDirection: "column" }, ...notices.slice(-3).map((notice, index) => createElement(Text, {
595
+ key: index,
596
+ dimColor: true
597
+ }, notice))), createElement(Input, {
598
+ busy,
599
+ descriptors,
600
+ skills,
601
+ dispatch: props.dispatch,
602
+ steer: props.steer,
603
+ interrupt: props.interrupt,
604
+ quit: props.quit,
605
+ openModel: () => {
606
+ setModelOpen(true);
607
+ },
608
+ notify
281
609
  }), createElement(StatusLine, {
282
610
  facts: {
283
- model,
284
- cwd,
285
- branch,
286
- sessionId
611
+ model: modelLabel,
612
+ cwd: props.cwd,
613
+ branch: props.branch,
614
+ sessionId: props.sessionId
287
615
  },
288
616
  stats: view.stats,
289
- busy: view.busy
617
+ busy
290
618
  }));
291
619
  }
292
620
  //#endregion
621
+ //#region src/approval.ts
622
+ /**
623
+ * Create the approval store and mount the answerer listener on the context.
624
+ * The listener claims only requests for `owns`-owned agents and defers every
625
+ * other request back into the waterfall (`next()`), so sibling answerers stay
626
+ * usable. An aborted ask never reaches the human. Plugin teardown removes the
627
+ * listener; the service then fails its own question closed.
628
+ * @param ctx - plugin context whose event bus carries `approval/request`.
629
+ * @param owns - agents this terminal answers for.
630
+ * @param preview - resolves a tool-call preview for a pending request (the
631
+ * request contract carries no arguments; the UI self-serves from the
632
+ * transcript projection via `callId`).
633
+ * @returns the store the renderer subscribes to.
634
+ */
635
+ function mountApprovalAnswerer(ctx, owns, preview) {
636
+ let snapshot = {
637
+ pending: void 0,
638
+ answered: false
639
+ };
640
+ const listeners = /* @__PURE__ */ new Set();
641
+ const set = (next) => {
642
+ snapshot = next;
643
+ for (const listener of listeners) listener();
644
+ };
645
+ ctx.on("approval/request", (request, next) => {
646
+ if (!owns(request.agent)) return next();
647
+ if (request.signal?.aborted === true) return Promise.resolve("cancelled");
648
+ let resolved = false;
649
+ let settle;
650
+ const withdraw = () => {
651
+ if (resolved) return;
652
+ resolved = true;
653
+ set({
654
+ pending: void 0,
655
+ answered: false
656
+ });
657
+ settle("cancelled");
658
+ };
659
+ if (request.signal !== void 0) request.signal.addEventListener("abort", withdraw, { once: true });
660
+ const pending = {
661
+ headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
662
+ toolName: request.toolName,
663
+ command: preview(request),
664
+ answer: (outcome) => {
665
+ if (resolved) return;
666
+ resolved = true;
667
+ set({
668
+ pending,
669
+ answered: true
670
+ });
671
+ settle(outcome);
672
+ }
673
+ };
674
+ set({
675
+ pending,
676
+ answered: false
677
+ });
678
+ return new Promise((resolve) => {
679
+ settle = resolve;
680
+ }).then((outcome) => {
681
+ if (outcome !== "cancelled") set({
682
+ pending: void 0,
683
+ answered: false
684
+ });
685
+ return outcome;
686
+ });
687
+ });
688
+ return {
689
+ subscribe(listener) {
690
+ listeners.add(listener);
691
+ return () => {
692
+ listeners.delete(listener);
693
+ };
694
+ },
695
+ getSnapshot() {
696
+ return snapshot;
697
+ }
698
+ };
699
+ }
700
+ //#endregion
701
+ //#region src/commands.ts
702
+ /**
703
+ * Watch the live command registry. Reads the current list immediately and
704
+ * re-reads on every registry mutation or agent retarget; notification
705
+ * failures are contained by the registry itself, so this watcher only ever
706
+ * re-reads. Without a `commands` service the view stays empty and all lines
707
+ * fall through to normal prompts.
708
+ * @param ctx - context carrying the `commands` service (optional).
709
+ * @returns the view the completion menu subscribes to.
710
+ */
711
+ function watchCommands(ctx) {
712
+ const commands = ctx.get("commands");
713
+ let agent;
714
+ let descriptors = [];
715
+ const listeners = /* @__PURE__ */ new Set();
716
+ const refresh = () => {
717
+ if (commands === void 0 || agent === void 0) return;
718
+ descriptors = commands.list(agent);
719
+ for (const listener of listeners) listener();
720
+ };
721
+ if (commands !== void 0) ctx.on("commands/change", () => refresh());
722
+ return {
723
+ get descriptors() {
724
+ return descriptors;
725
+ },
726
+ subscribe(listener) {
727
+ listeners.add(listener);
728
+ return () => {
729
+ listeners.delete(listener);
730
+ };
731
+ },
732
+ setAgent(next) {
733
+ agent = next;
734
+ refresh();
735
+ }
736
+ };
737
+ }
738
+ /**
739
+ * Whether one command line is a syntactically valid slash command.
740
+ * @param line - the complete candidate line.
741
+ * @returns true when the line parses as `/name` or `/name input`.
742
+ */
743
+ function isSlashLine(line) {
744
+ return /^\/[a-z][a-z0-9_-]*(?=$|[\t ])/u.test(line);
745
+ }
746
+ //#endregion
293
747
  //#region src/internals.ts
294
748
  /**
295
749
  * Injectable process-facing effects for the TUI runner. Tests substitute the
@@ -309,6 +763,50 @@ const internals = {
309
763
  stderr: process.stderr
310
764
  };
311
765
  //#endregion
766
+ //#region src/models.ts
767
+ /**
768
+ * Load the selectable model directory from the live `ctx.llm` registry.
769
+ * Providers are listed synchronously; each provider's models are discovered
770
+ * with a bounded parallel fan-out whose failures degrade to that provider
771
+ * contributing no rows (mirrors the web catalog's per-provider failures).
772
+ * @param ctx - context carrying the `llm` service.
773
+ * @returns the resolved directory; empty rows when `llm` is unavailable.
774
+ */
775
+ async function loadModelDirectory(ctx) {
776
+ const llm = ctx.get("llm");
777
+ if (llm === void 0) return {
778
+ rows: [],
779
+ failures: []
780
+ };
781
+ const providers = llm.listProviders();
782
+ const listed = await Promise.all(providers.map(async (provider) => {
783
+ try {
784
+ const models = await llm.listModels(provider.id);
785
+ return {
786
+ provider: provider.id,
787
+ providerName: provider.name,
788
+ models: models.map((model) => ({
789
+ provider: provider.id,
790
+ providerName: provider.name,
791
+ model: model.id,
792
+ modelName: model.name
793
+ }))
794
+ };
795
+ } catch {
796
+ return {
797
+ provider: provider.id,
798
+ providerName: provider.name,
799
+ models: [],
800
+ failed: true
801
+ };
802
+ }
803
+ }));
804
+ return {
805
+ rows: listed.flatMap((entry) => entry.models),
806
+ failures: listed.filter((entry) => "failed" in entry && entry.failed === true).map((entry) => entry.provider)
807
+ };
808
+ }
809
+ //#endregion
312
810
  //#region src/render/projection.ts
313
811
  /**
314
812
  * Pure session-event-to-view projection for the TUI transcript: one reducer
@@ -329,6 +827,7 @@ function createTranscriptView() {
329
827
  streaming: "",
330
828
  todos: [],
331
829
  busy: false,
830
+ model: "",
332
831
  stats: {
333
832
  turns: 0,
334
833
  steps: 0,
@@ -448,6 +947,7 @@ function projectEvent(view, event) {
448
947
  case "turn/start": return {
449
948
  ...view,
450
949
  busy: true,
950
+ todos: [],
451
951
  stats: {
452
952
  ...view.stats,
453
953
  turns: view.stats.turns + 1
@@ -477,17 +977,67 @@ function projectEvent(view, event) {
477
977
  }]
478
978
  };
479
979
  }
980
+ case "request/header": {
981
+ const config = event.data.header.config;
982
+ return {
983
+ ...view,
984
+ model: `${config.provider}/${config.model}`
985
+ };
986
+ }
987
+ case "command/run": {
988
+ const data = event.data;
989
+ return {
990
+ ...view,
991
+ entries: [...view.entries, {
992
+ kind: "command",
993
+ commandId: data.commandId,
994
+ name: data.name,
995
+ args: data.args ?? "",
996
+ state: "running",
997
+ summary: ""
998
+ }]
999
+ };
1000
+ }
1001
+ case "command/done": {
1002
+ const data = event.data;
1003
+ const entries = view.entries.map((entry) => {
1004
+ if (entry.kind !== "command" || entry.commandId !== data.commandId) return entry;
1005
+ return {
1006
+ ...entry,
1007
+ state: data.kind === "success" ? "done" : "error",
1008
+ summary: boundContextSummary(data.text ?? "")
1009
+ };
1010
+ });
1011
+ return {
1012
+ ...view,
1013
+ entries
1014
+ };
1015
+ }
480
1016
  default: return view;
481
1017
  }
482
1018
  }
1019
+ /**
1020
+ * Fold a replayed event history into one view.
1021
+ * @param events - events in `seq` order.
1022
+ * @returns the folded view.
1023
+ */
1024
+ function projectEvents(events) {
1025
+ return events.reduce(projectEvent, createTranscriptView());
1026
+ }
483
1027
  //#endregion
484
1028
  //#region src/store.ts
485
1029
  /**
486
- * Create one transcript store.
1030
+ * Create one transcript store, optionally seeded with replayed history. The
1031
+ * seed folds synchronously BEFORE the first render, so a resumed session
1032
+ * paints its full transcript on mount (no live `session/event` fires for
1033
+ * constructor seeds — the store's `session/event` feed only carries new
1034
+ * appends).
1035
+ * @param replay - persisted events in `seq` order (e.g. a resumed session's
1036
+ * constructor seed); folded once and never re-notified.
487
1037
  * @returns the store the runner feeds and the renderer subscribes to.
488
1038
  */
489
- function createTranscriptStore() {
490
- let view = createTranscriptView();
1039
+ function createTranscriptStore(replay) {
1040
+ let view = replay === void 0 ? createTranscriptView() : projectEvents(replay);
491
1041
  const listeners = /* @__PURE__ */ new Set();
492
1042
  return {
493
1043
  getView: () => view,
@@ -506,16 +1056,69 @@ function createTranscriptStore() {
506
1056
  };
507
1057
  }
508
1058
  //#endregion
1059
+ //#region src/skills.ts
1060
+ function toRows(skills) {
1061
+ return skills.filter((skill) => isUserInvocable(skill)).map((skill) => ({
1062
+ name: skill.name,
1063
+ description: skill.description,
1064
+ modelInvocable: skill.invocation.modelInvocable === true
1065
+ })).sort((left, right) => left.name < right.name ? -1 : 1);
1066
+ }
1067
+ /**
1068
+ * Watch the user-invocable skill catalog for one agent's workspace. The first
1069
+ * load starts when the owning agent is known (`setAgent`); `skills/change`
1070
+ * and agent retargets re-read. Read failures keep the last good rows (the
1071
+ * next change notification is the retry surface) — a missing `skills`
1072
+ * service leaves the view permanently empty.
1073
+ * @param ctx - context carrying the `skills` service (optional).
1074
+ * @returns the view the completion menu subscribes to.
1075
+ */
1076
+ function watchSkills(ctx) {
1077
+ const skills = ctx.get("skills");
1078
+ let agent;
1079
+ let rows = [];
1080
+ const listeners = /* @__PURE__ */ new Set();
1081
+ const reload = () => {
1082
+ if (skills === void 0 || agent === void 0) return;
1083
+ skills.list({
1084
+ cwd: agent.session.header.cwd,
1085
+ scope: agent
1086
+ }).then((summaries) => {
1087
+ const next = toRows(summaries);
1088
+ if (next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)) return;
1089
+ rows = next;
1090
+ for (const listener of listeners) listener();
1091
+ }, () => {});
1092
+ };
1093
+ if (skills !== void 0) ctx.on("skills/change", reload);
1094
+ return {
1095
+ get rows() {
1096
+ return rows;
1097
+ },
1098
+ subscribe(listener) {
1099
+ listeners.add(listener);
1100
+ return () => {
1101
+ listeners.delete(listener);
1102
+ };
1103
+ },
1104
+ setAgent(next) {
1105
+ agent = next;
1106
+ reload();
1107
+ }
1108
+ };
1109
+ }
1110
+ //#endregion
509
1111
  //#region src/index.ts
510
1112
  /**
511
- * @deepseek-ai/dsh-tui — the interactive terminal driver. The bundle patch
1113
+ * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
512
1114
  * rides over dsh-base without Host, HTTP, or browser plugins; this runner
513
- * creates one Agent through the core registry, mounts the Ink app (DeepSeek
514
- * blue, whale wordmark), folds submitted prompts into the same durable
515
- * session, streams `session/event` into the transcript, and on quit flushes
516
- * and requests process exit.
1115
+ * creates (or resumes) one Agent through the core registry, mounts the Ink
1116
+ * app (DeepSeek blue, whale wordmark), folds submitted prompts into the same
1117
+ * durable session, answers approval asks with a y/n bar, dispatches slash
1118
+ * commands through the shared registry, and on quit flushes and requests
1119
+ * process exit.
517
1120
  *
518
- * @module @deepseek-ai/dsh-tui
1121
+ * @module @deepseek-ai/dsh-code
519
1122
  */
520
1123
  /** Stable Cordis plugin name. */
521
1124
  const name = "tui-runner";
@@ -525,6 +1128,10 @@ const inject = [
525
1128
  "agents",
526
1129
  "sessions"
527
1130
  ];
1131
+ const Config = z.object({ startup: z.object({
1132
+ kind: z.string().required(),
1133
+ sessionId: z.string()
1134
+ }) });
528
1135
  /** Report an unexpected direct-driver failure and request a failing exit. */
529
1136
  function fail(io, error) {
530
1137
  internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -543,36 +1150,155 @@ function gitBranch(cwd) {
543
1150
  }
544
1151
  }
545
1152
  /**
546
- * Run the interactive terminal session: create one Agent, mount the app, and
547
- * keep the process alive until the user quits.
1153
+ * Resolve the invocation's target session against the persisted headers.
1154
+ * @param startup - the parsed startup flags.
1155
+ * @param persistence - the persistence service; required for resume/latest.
1156
+ * @param cwd - the working directory `--continue` filters by.
1157
+ * @returns the target identity.
1158
+ * @throws with a user-facing message when the flags name nothing resolvable.
1159
+ */
1160
+ async function resolveTarget(startup, persistence, cwd) {
1161
+ if (startup.kind === "fresh") return {
1162
+ sessionId: `session-${randomUUID()}`,
1163
+ resume: false
1164
+ };
1165
+ if (startup.kind === "named") return {
1166
+ sessionId: startup.sessionId,
1167
+ resume: false
1168
+ };
1169
+ if (persistence === void 0) throw new Error("cannot resolve the requested session: session persistence is not configured");
1170
+ const headers = await persistence.list();
1171
+ if (startup.kind === "resume") {
1172
+ const wanted = startup.sessionId;
1173
+ const exact = headers.filter((header) => header.id === wanted);
1174
+ const matches = exact.length > 0 ? exact : headers.filter((header) => header.id.startsWith(wanted));
1175
+ if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`);
1176
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`);
1177
+ return {
1178
+ sessionId: matches[0].id,
1179
+ resume: true
1180
+ };
1181
+ }
1182
+ const local = headers.filter((header) => header.cwd === cwd).sort((left, right) => right.createdAt - left.createdAt);
1183
+ if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`);
1184
+ return {
1185
+ sessionId: local[0].id,
1186
+ resume: true
1187
+ };
1188
+ }
1189
+ /**
1190
+ * Resolve a bounded command preview for one pending approval: the request
1191
+ * contract carries no arguments, so the bar self-serves from the transcript
1192
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
1193
+ * @param events - the transcript entries to search.
1194
+ * @param callId - the tool call the question is about, when the asker had one.
1195
+ * @param toolName - the tool the question is about.
1196
+ * @returns a bounded preview line, '' when nothing useful resolves.
1197
+ */
1198
+ function approvalCommandPreview(events, callId, toolName) {
1199
+ if (callId === void 0) return "";
1200
+ const entry = events.find((candidate) => candidate.kind === "tool" && candidate.callId === callId);
1201
+ if (entry === void 0) return "";
1202
+ const args = entry.arguments ?? "";
1203
+ try {
1204
+ const parsed = JSON.parse(args);
1205
+ if (parsed !== null && typeof parsed === "object") {
1206
+ const record = parsed;
1207
+ for (const key of [
1208
+ "command",
1209
+ "cmd",
1210
+ "description",
1211
+ "path",
1212
+ "pattern",
1213
+ "query"
1214
+ ]) {
1215
+ const value = record[key];
1216
+ if (typeof value === "string" && value !== "") return value;
1217
+ }
1218
+ }
1219
+ } catch {}
1220
+ return args.length > 80 ? `${args.slice(0, 77)}...` : args === "" ? toolName : args;
1221
+ }
1222
+ /**
1223
+ * Run the interactive terminal session: resolve the target session, create or
1224
+ * resume one Agent, mount the app, and keep the process alive until the user
1225
+ * quits.
548
1226
  * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
1227
+ * @param startup - the parsed invocation flags.
549
1228
  * @param io - process-facing effects.
550
1229
  */
551
- async function run(ctx, io) {
1230
+ async function run(ctx, startup, io) {
552
1231
  await ctx.get("loader")?.await();
553
1232
  const agents = ctx.get("agents");
554
1233
  const defaultModel = ctx.get("agentDefaultModel");
555
1234
  const sessions = ctx.get("sessions");
1235
+ const persistence = ctx.get("sessionPersistence");
556
1236
  if (agents === void 0 || defaultModel === void 0 || sessions === void 0) return;
557
- const selection = defaultModel.currentSelection();
558
- const { agent } = await agents.create({
559
- sessionId: SessionId(`session-${randomUUID()}`),
560
- meta: { cwd: process.cwd() },
561
- agentOptions: {
562
- provider: selection.provider,
563
- model: selection.model
564
- },
565
- setup: (agentCtx) => {
566
- installModelSelection(agentCtx, {
567
- current: selection,
568
- assembled: void 0
569
- });
570
- }
571
- });
572
- const store = createTranscriptStore();
573
- const off = ctx.on("session/event", (session, event) => {
574
- if (session.id === agent.session.id) store.apply(event);
1237
+ const cwd = process.cwd();
1238
+ const target = await resolveTarget(startup, persistence, cwd);
1239
+ const defaults = defaultModel.currentSelection();
1240
+ let picked;
1241
+ let session;
1242
+ let agent;
1243
+ if (target.resume) {
1244
+ agent = (await agents.resume({
1245
+ resumeSessionId: SessionId(target.sessionId),
1246
+ agentOptions: {
1247
+ provider: defaults.provider,
1248
+ model: defaults.model
1249
+ },
1250
+ setup: (agentCtx) => {
1251
+ installModelSelection(agentCtx, {
1252
+ get current() {
1253
+ if (picked !== void 0) return picked;
1254
+ const logged = agentCtx.agent?.session.requestHeader()?.config;
1255
+ if (logged !== void 0) return {
1256
+ provider: logged.provider,
1257
+ model: logged.model,
1258
+ ...logged.reasoningEffort === void 0 ? {} : { reasoningEffort: logged.reasoningEffort }
1259
+ };
1260
+ return defaults;
1261
+ },
1262
+ set current(next) {
1263
+ picked = next;
1264
+ },
1265
+ assembled: void 0
1266
+ });
1267
+ }
1268
+ })).agent;
1269
+ session = agent.session;
1270
+ } else {
1271
+ agent = (await agents.create({
1272
+ sessionId: SessionId(target.sessionId),
1273
+ meta: { cwd },
1274
+ agentOptions: {
1275
+ provider: defaults.provider,
1276
+ model: defaults.model
1277
+ },
1278
+ setup: (agentCtx) => {
1279
+ installModelSelection(agentCtx, {
1280
+ get current() {
1281
+ return picked ?? defaults;
1282
+ },
1283
+ set current(next) {
1284
+ picked = next;
1285
+ },
1286
+ assembled: void 0
1287
+ });
1288
+ }
1289
+ })).agent;
1290
+ session = agent.session;
1291
+ }
1292
+ const store = createTranscriptStore(session.events);
1293
+ const off = ctx.on("session/event", (subject, event) => {
1294
+ if (subject.id === session.id) store.apply(event);
575
1295
  });
1296
+ const commands = watchCommands(ctx);
1297
+ commands.setAgent(agent);
1298
+ const skills = watchSkills(ctx);
1299
+ skills.setAgent(agent);
1300
+ const approval = mountApprovalAnswerer(ctx, (candidate) => candidate.id === agent.id, (request) => approvalCommandPreview(store.getView().entries, request.callId, request.toolName));
1301
+ const bridge = { notify: () => {} };
576
1302
  const mountRef = {};
577
1303
  let quitting = false;
578
1304
  const quit = () => {
@@ -580,44 +1306,120 @@ async function run(ctx, io) {
580
1306
  quitting = true;
581
1307
  off();
582
1308
  mountRef.current?.unmount();
583
- sessions.flush(agent.session).catch((flushError) => {
1309
+ sessions.flush(session).catch((flushError) => {
584
1310
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`);
585
1311
  }).then(() => {
586
1312
  io.exit(0);
587
1313
  });
588
1314
  };
1315
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
1316
+ const dispatch = (text) => {
1317
+ const line = text.trim();
1318
+ if (line === "") return;
1319
+ if (isSlashLine(line)) {
1320
+ const registry = ctx.get("commands");
1321
+ if (registry === void 0) {
1322
+ bridge.notify("no command registry is mounted in this composition");
1323
+ return;
1324
+ }
1325
+ const controller = new AbortController();
1326
+ registry.execute(agent, line, controller.signal).then((execution) => {
1327
+ if (execution === void 0) agent.followup(createUserMessage({
1328
+ content: [{
1329
+ type: "text",
1330
+ text: line
1331
+ }],
1332
+ source: { kind: "user" }
1333
+ }));
1334
+ }, (error) => {
1335
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
1336
+ });
1337
+ return;
1338
+ }
1339
+ agent.followup(createUserMessage({
1340
+ content: [{
1341
+ type: "text",
1342
+ text: line
1343
+ }],
1344
+ source: { kind: "user" }
1345
+ }));
1346
+ };
1347
+ /**
1348
+ * Submit steering: a running driver consumes the text at its next step
1349
+ * boundary (the inbox delivers between steps); an idle driver just starts
1350
+ * a turn, so this doubles as the busy-state submit path.
1351
+ */
1352
+ const steer = (text) => {
1353
+ const line = text.trim();
1354
+ if (line === "") return;
1355
+ agent.steer(createUserMessage({
1356
+ content: [{
1357
+ type: "text",
1358
+ text: line
1359
+ }],
1360
+ source: { kind: "user" }
1361
+ }));
1362
+ bridge.notify("steering queued — the next step sees it");
1363
+ };
1364
+ /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
1365
+ const interrupt = () => {
1366
+ if (agent.status !== "running") return false;
1367
+ agent.cancel({ kind: "user" });
1368
+ bridge.notify("turn cancelled — Ctrl+C or /quit to exit");
1369
+ return true;
1370
+ };
1371
+ /** Apply one /model selection: takes effect from the next assembled step. */
1372
+ const selectModel = (row) => {
1373
+ picked = {
1374
+ provider: row.provider,
1375
+ model: row.model
1376
+ };
1377
+ return `${row.provider}/${row.model}`;
1378
+ };
1379
+ const initialModel = store.getView().model !== "" ? store.getView().model : `${defaults.provider}/${defaults.model}`;
589
1380
  mountRef.current = io.mount(createElement(App, {
590
1381
  store,
591
- model: `${selection.provider}/${selection.model}`,
592
- cwd: basename(process.cwd()),
593
- branch: gitBranch(process.cwd()),
594
- sessionId: agent.session.id.slice(-8),
595
- onSubmit: (text) => {
596
- agent.followup(createUserMessage({
597
- content: [{
598
- type: "text",
599
- text
600
- }],
601
- source: { kind: "user" }
602
- }));
603
- },
604
- onQuit: quit
1382
+ approval,
1383
+ commands,
1384
+ skills,
1385
+ model: initialModel,
1386
+ cwd: basename(cwd),
1387
+ branch: gitBranch(cwd),
1388
+ sessionId: session.id.slice(-8),
1389
+ resumed: target.resume,
1390
+ dispatch,
1391
+ steer,
1392
+ interrupt,
1393
+ quit,
1394
+ loadModels: () => loadModelDirectory(ctx),
1395
+ selectModel,
1396
+ onBridgeReady: (instance) => {
1397
+ bridge.notify = instance.notify;
1398
+ }
605
1399
  }));
606
1400
  }
607
1401
  /**
608
1402
  * Mount the interactive terminal driver.
609
1403
  * @param ctx - plugin context carrying core services and the launcher-provided exit request.
1404
+ * @param config - validated startup config resolved from the tuiStartup provider.
610
1405
  */
611
- function apply(ctx) {
1406
+ function apply(ctx, config) {
1407
+ const startup = config.startup.kind === "resume" && config.startup.sessionId !== void 0 ? {
1408
+ kind: "resume",
1409
+ sessionId: config.startup.sessionId
1410
+ } : config.startup.kind === "latest" ? { kind: "latest" } : config.startup.kind === "named" && config.startup.sessionId !== void 0 ? {
1411
+ kind: "named",
1412
+ sessionId: config.startup.sessionId
1413
+ } : { kind: "fresh" };
612
1414
  const exit = ctx.get("appExit");
613
1415
  if (exit === void 0) throw new Error("tui-runner: the launcher must provide ctx.appExit before the tree mounts");
614
1416
  const io = {
615
1417
  mount: internals.mount,
616
1418
  exit
617
1419
  };
618
- run(ctx, io).catch((error) => {
1420
+ run(ctx, startup, io).catch((error) => {
619
1421
  fail(io, error);
620
1422
  });
621
1423
  }
622
1424
  //#endregion
623
- export { apply, inject, name };
1425
+ export { Config, apply, inject, name };