@vincemakes/kiso-code 0.46.2 → 0.47.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/dist/dispatch.js CHANGED
@@ -3,29 +3,30 @@
3
3
  * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
- import { STATUS_GLYPHS, contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, namedPickView, compactingStatus, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
6
+ import { STATUS_GLYPHS, contextRows, contextSheetRows, contextUnavailableRows, contextUnavailableSheetRows, displayVerb, escapeTerminal, helpRows, toolTarget, infoSheetRows, kUnit, modePickView, modelPickView, namedPickView, settingsPickView, compactingStatus, liveRow, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
7
7
  import { newSessionId } from "./session-id.js";
8
- import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
8
+ import { buildAdapter, lookupModelMetadata, readSessionName, resolveContinuationScope, resolveReasoning, sessionTitle, tiersFor, writeSessionName } from "@vincemakes/kiso-runtime/internal";
9
9
  import { MODE_LABEL, MODE_NOTE, OFFERED_MODES, applyModeSetting, getDontAsk, getMode, modeDisplay, parseMode, setDontAsk } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
11
  import { protectedBangReason, protectedShellVerdict } from "./protected-shell.js";
12
- import { agentBaseUrl, currentProfileName, setCurrentProfileName, currentModelName, agentModel, body, bodyLog, codingToolOptions, protectedFiles, kisoHome, configModels, dock, lastBinding, loadedSkillsCatalog, mergedConfig, readContextLedger, retryOnRow, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setRetryShown, upstreamOf, VERSION, setLastBinding } from "./state.js";
12
+ import { activeStoreDir, agentBaseUrl, currentProfileName, setCurrentProfileName, currentModelName, agentModel, body, bodyLog, codingToolOptions, protectedFiles, kisoHome, configModels, dock, lastBinding, loadedSkillsCatalog, mergedConfig, readContextLedger, retryOnRow, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setRetryShown, upstreamOf, VERSION, setLastBinding } from "./state.js";
13
13
  import { adapterOptionsFor } from "./auth/adapter-options.js";
14
14
  import { profileProviderLabel, providerLabel } from "./provider-label.js";
15
15
  import { installedVersion, versionStatusLine } from "./stale-version.js";
16
16
  import { preferences, rememberEffort, setPreference } from "./preferences.js";
17
- import { settingsRows } from "./settings.js";
17
+ import { settingRow, settingsFacts, settingsRows } from "./settings.js";
18
18
  import { floorOn, settingsLayers } from "./state.js";
19
19
  import { currentGround } from "@vincemakes/kiso-tui-cells/render";
20
20
  import { queuedSwitchLines, tasksFor } from "./state.js";
21
- import { lostReason, taskOutput, taskRow, taskStateLabel } from "./task-notice.js";
21
+ import { lostReason, taskOutput, taskRow, taskStateLabel, taskOutputSheetRows } from "./task-notice.js";
22
22
  import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner, statedContextWindow, windowSourceNote } from "./chat.js";
23
23
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
24
24
  import { shellTool } from "@vincemakes/kiso-tools-node";
25
25
  import { dirname, join } from "node:path";
26
26
  import { realpathSync } from "node:fs";
27
27
  import { homedir } from "node:os";
28
- import { resolveSkillLine, skillsRows } from "./skill-invoke.js";
28
+ import { resolveSkillLine, skillsRows, skillsSheetRows } from "./skill-invoke.js";
29
+ import { setTitleName, setTitleState } from "./window-title.js";
29
30
  /** Where a skill directory really lives. Project and user skills are
30
31
  * merged into one scan directory by symlink, so the scan root names a
31
32
  * temp directory; the link's target is the answer a person can use. */
@@ -133,6 +134,17 @@ function signInNote(p) {
133
134
  }
134
135
  return p.apiKeyEnv ?? "no key";
135
136
  }
137
+ /** Graphite P3: why a profile cannot run, for its opened row — the words a
138
+ * pick of it prints (`unavailableReason`), without the `model <name>:
139
+ * unavailable —` the row already says and the config aside. */
140
+ function unavailableWhy(name, p) {
141
+ const head = `model ${name}: `;
142
+ const said = unavailableReason(name, p);
143
+ return (said.startsWith(head) ? said.slice(head.length) : said)
144
+ .replace(/^unavailable \u2014 /, "")
145
+ .replace(/ \(configs never store keys, only the env-var name\)$/, "")
146
+ .replaceAll("`", "");
147
+ }
136
148
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
137
149
  * replay routes through it too — a queued "/last" must never become a
138
150
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -207,7 +219,10 @@ function runBang(command, send, ctx) {
207
219
  ctx.submitTurn(block);
208
220
  return; // submitTurn owns the prompt from here
209
221
  }
210
- bodyLog(block, "words");
222
+ // G3 (R2d): `!!` is the person's command card, `not sent` — its
223
+ // output escaped like every tool's (the raw block this replaced
224
+ // printed the fence's markers and did not escape the output)
225
+ body.bang(command, result.content, result.isError === true);
211
226
  }
212
227
  finally {
213
228
  activeBang = null;
@@ -215,6 +230,19 @@ function runBang(command, send, ctx) {
215
230
  ctx.input.prompt();
216
231
  });
217
232
  }
233
+ /** Graphite R3d: a name is cut where a derived title is (`sessionTitle`). */
234
+ const NAME_MAX = 60;
235
+ /** Graphite §8.7 (R3b): the next /compact was dispatched by kiso, not
236
+ * typed — its row says why: `auto` (the opt-in threshold), or P1b's
237
+ * `cold cache` (a resumed session whose cache expired). Typed, it is
238
+ * `manual`. Read once by the next /compact. */
239
+ let nextCompactWhy = null;
240
+ export function markCompactWhy(why) {
241
+ nextCompactWhy = why;
242
+ }
243
+ export function markAutoCompact() {
244
+ markCompactWhy("auto");
245
+ }
218
246
  export function dispatch(line, ctx) {
219
247
  // DC-57 (the owner's ruling, 2026-09-21): a session that is LEAVING — a
220
248
  // switch or a reload has been requested from an earlier segment of the
@@ -268,6 +296,18 @@ export function dispatch(line, ctx) {
268
296
  // between any two elements, so a row-per-call group comes out
269
297
  // double-spaced — which is what the owner photographed on the
270
298
  // ctrl+o expansion.
299
+ //
300
+ // The sheets round (owner, 2026-10-06): on a dock /help opens the
301
+ // command list — the band a typed `/` opens, every command and
302
+ // installed skill with its description, filtered as you type —
303
+ // so the list you read is the list you pick from (§8.16). Its
304
+ // keys and gestures are the keys sheet's (`?`). Off a dock (a
305
+ // pipe, -p) the table below is what it always printed.
306
+ if (dock.active && ctx.input.openCommands !== undefined) {
307
+ ctx.input.openCommands();
308
+ ctx.input.prompt();
309
+ return;
310
+ }
271
311
  bodyLog(helpRows().join("\n"), "words");
272
312
  ctx.input.prompt();
273
313
  });
@@ -279,10 +319,13 @@ export function dispatch(line, ctx) {
279
319
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
280
320
  const t = body.lastThinking();
281
321
  if (t === null) {
282
- bodyLog("[no thinking yet]");
322
+ body.notice("[no thinking yet]");
283
323
  }
284
324
  else {
285
- bodyLog(escapeTerminal(t));
325
+ // the last sweep (owner, 2026-10-06): on the terminal a THINKING
326
+ // row and the block in thinking's own look; a pipe prints the text
327
+ const lines = t.trim().split("\n").length;
328
+ body.recall(escapeTerminal(t), "THINKING", `the last block \u00b7 ${lines} line${lines === 1 ? "" : "s"}`, [{ text: t, style: "thinking" }]);
286
329
  }
287
330
  ctx.input.prompt();
288
331
  });
@@ -295,7 +338,7 @@ export function dispatch(line, ctx) {
295
338
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
296
339
  const tool = body.lastTool();
297
340
  if (tool === null) {
298
- bodyLog("[no tool call yet]");
341
+ body.notice("[no tool call yet]");
299
342
  }
300
343
  else {
301
344
  // TUI2-R2pre ④: the SECTION HEADERS say the act ("--- read
@@ -309,11 +352,21 @@ export function dispatch(line, ctx) {
309
352
  // is a machine-readable log whose bytes other things (the
310
353
  // e2e gates, anyone's script) already depend on. Without the
311
354
  // dock, this is that log — so it keeps the call's own name.
312
- const verb = dock.active ? displayVerb(tool.name) : tool.name;
313
- bodyLog(`--- ${verb} input ---`);
314
- bodyLog(escapeTerminal(JSON.stringify(tool.input, null, 2)));
315
- bodyLog(`--- ${verb} output${tool.result.isError ? " (error)" : ""} ---`);
316
- bodyLog(escapeTerminal(tool.result.content));
355
+ //
356
+ // The last sweep (owner, 2026-10-06): the screen's form is no
357
+ // longer the rules with a display verb — it is a LAST CALL row
358
+ // (the card's verb and target, how much came back) and the two
359
+ // payloads under dim titles, one cell. The pipe keeps the log.
360
+ const input = escapeTerminal(JSON.stringify(tool.input, null, 2));
361
+ const output = escapeTerminal(tool.result.content);
362
+ const pipe = [`--- ${tool.name} input ---`, input, `--- ${tool.name} output${tool.result.isError ? " (error)" : ""} ---`, output].join("\n");
363
+ const n = tool.result.content.replace(/\n+$/, "").split("\n").length;
364
+ const head = `${displayVerb(tool.name).toUpperCase()} ${toolTarget(tool.name, tool.input)}`.trim();
365
+ const said = tool.result.content === "" ? "no output" : `${n} line${n === 1 ? "" : "s"}${tool.result.isError ? " \u00b7 failed" : ""}`;
366
+ body.recall(pipe, "LAST CALL", `${head} \u00b7 ${said}`, [
367
+ { title: "input", text: input, style: "output" },
368
+ { title: tool.result.isError ? "output (error)" : "output", text: output, style: "output" },
369
+ ]);
317
370
  }
318
371
  ctx.input.prompt();
319
372
  });
@@ -445,7 +498,7 @@ export function dispatch(line, ctx) {
445
498
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
446
499
  const answer = lastAnswer(ctx.session.projected());
447
500
  if (answer === null) {
448
- bodyLog("[nothing to copy — no answer yet]");
501
+ body.notice("[nothing to copy — no answer yet]");
449
502
  }
450
503
  else {
451
504
  const r = clipboardWrite(answer);
@@ -454,7 +507,7 @@ export function dispatch(line, ctx) {
454
507
  // the dock it rides the W18 hint slot (gone at the next
455
508
  // keypress); in a pipe it is a line like any other.
456
509
  if (dock.active)
457
- dock.setStatus(r.message);
510
+ dock.flash(r.message);
458
511
  else
459
512
  bodyLog(`[${r.message}]`);
460
513
  }
@@ -473,13 +526,13 @@ export function dispatch(line, ctx) {
473
526
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
474
527
  const r = body.rewrap();
475
528
  if (r.lines.length === 0) {
476
- bodyLog("[/rewrap] no prose to re-wrap yet");
529
+ body.notice("[/rewrap] no prose to re-wrap yet");
477
530
  }
478
531
  else {
479
- bodyLog(`--- re-wrapped ${r.blocks} block${r.blocks === 1 ? "" : "s"} at the current width (appended — the history above is unchanged) ---`);
532
+ body.notice(`--- re-wrapped ${r.blocks} block${r.blocks === 1 ? "" : "s"} at the current width (appended — the history above is unchanged) ---`);
480
533
  bodyLog(r.lines.join("\n")); // DC-51: one call, one cell
481
534
  if (r.skipped > 0)
482
- bodyLog(`--- ${r.skipped} earlier block${r.skipped === 1 ? "" : "s"} not re-wrapped (bounded at two screens) ---`);
535
+ body.notice(`--- ${r.skipped} earlier block${r.skipped === 1 ? "" : "s"} not re-wrapped (bounded at two screens) ---`);
483
536
  }
484
537
  ctx.input.prompt();
485
538
  });
@@ -500,8 +553,20 @@ export function dispatch(line, ctx) {
500
553
  // derivation still does zero I/O and still ignores trace-shaped data.
501
554
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
502
555
  const ledger = readContextLedger(ctx.session.id, ctx.contextWindow());
556
+ const reason = "the ledger is written per request \u2014 run a turn, then ask again";
557
+ // the sheets round (owner, 2026-10-06): on a dock /context is a
558
+ // sheet over the input, read and then typed past (§8.16); off one
559
+ // (a pipe, -p) the lines below are what it always printed
560
+ if (dock.active && ctx.input.openSheet !== undefined) {
561
+ const win = ctx.contextWindow();
562
+ const t = win > 0 ? tiersFor(win, 0) : null;
563
+ const tiers = t === null ? null : { soft: t.soft / win, hard: t.hard / win };
564
+ ctx.input.openSheet((W) => (ledger === null ? contextUnavailableSheetRows(reason, W) : contextSheetRows(ledger, tiers, W)));
565
+ ctx.input.prompt();
566
+ return;
567
+ }
503
568
  for (const row of ledger === null
504
- ? contextUnavailableRows("the ledger is written per request — run a turn, then ask again")
569
+ ? contextUnavailableRows(reason)
505
570
  : contextRows(ledger)) {
506
571
  bodyLog(row);
507
572
  }
@@ -515,7 +580,7 @@ export function dispatch(line, ctx) {
515
580
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
516
581
  const w = statedContextWindow();
517
582
  const profile = currentProfileName;
518
- bodyLog(settingsRows({
583
+ const input = {
519
584
  user: settingsLayers.user,
520
585
  project: settingsLayers.project,
521
586
  env: process.env,
@@ -531,7 +596,128 @@ export function dispatch(line, ctx) {
531
596
  thinkingHidden: body.thinkingHidden(),
532
597
  thinkingRemembered: preferences().thinking !== undefined,
533
598
  version: versionStatusLine(installedVersion(), VERSION).replace(/^version /, ""),
534
- }).join("\n\n"), "words");
599
+ };
600
+ // Graphite R3e (owner, 2026-09-29): on a dock /settings is a panel.
601
+ // The session's own settings change in it — the mode (←→, the same
602
+ // path as /mode), thinking shown/hidden (←→, ctrl+t's path), the
603
+ // model (⏎ opens /model). What lives in a config file shows its
604
+ // value and source, and ⏎ prints how to change it: kiso never
605
+ // writes the person's config (0.40.6's rule stands for those).
606
+ if (dock.active && ctx.input.panelAsk !== undefined) {
607
+ const facts = settingsFacts(input);
608
+ const mode = getMode();
609
+ const hidden = body.thinkingHidden();
610
+ const THINK = ["shown", "hidden"];
611
+ const DONT_ASK = ["off", "on"];
612
+ // Graphite P3 (owner, 2026-10-04): the session's own four first —
613
+ // model, mode, don't ask, thinking — then what lives in a config
614
+ // file. An order, not groups: no caption between them. The printed
615
+ // form keeps its own order.
616
+ const OWN = ["model", "mode", "don't ask", "thinking"];
617
+ const order = [...OWN.flatMap((n) => facts.filter((f) => f.name === n)), ...facts.filter((f) => !OWN.includes(f.name))];
618
+ const options = order.map((f) => {
619
+ const cols = [f.brief ?? f.value, f.from];
620
+ if (f.name === "mode")
621
+ return { label: "mode", cols: [MODE_LABEL[mode] ?? f.value, f.from], levels: OFFERED_MODES.map((m) => MODE_LABEL[m]), axisLabel: "mode", enter: "\u23ce applies", ...(OFFERED_MODES.indexOf(mode) >= 0 ? { level: OFFERED_MODES.indexOf(mode) } : {}) };
622
+ // the switch is the session's own, as /dont-ask is: walked here, never written to a config
623
+ if (f.name === "don't ask")
624
+ return { label: "don't ask", cols, levels: DONT_ASK, level: getDontAsk() ? 1 : 0, axisLabel: "don't ask", enter: "\u23ce applies" };
625
+ if (f.name === "thinking")
626
+ return { label: "thinking", cols, levels: THINK, level: hidden ? 1 : 0, axisLabel: "thinking", enter: "\u23ce applies" };
627
+ if (f.name === "model") {
628
+ const host = providerLabel(agentBaseUrl, upstreamOf(agentBaseUrl)).replace(/^@/, "");
629
+ return { label: "model", cols: [agentModel, f.from], opened: [host, profile === null ? "" : `profile ${profile}`].filter((t) => t !== "").join(" \u00b7 "), enter: "\u23ce opens /model" };
630
+ }
631
+ // a config file's setting: what its value means, and how it changes
632
+ return { label: f.name, cols, opened: [f.means ?? "", `change: ${f.change}`].filter((t) => t !== "").join(" \u00b7 "), enter: "\u23ce prints how" };
633
+ });
634
+ let level;
635
+ const picked = await new Promise((resolve) => {
636
+ ctx.input.panelAsk(settingsPickView({ header: "settings", noun: "", input: "arrows", columns: ["plain", "dim"], levelColumn: 0, ...(ctx.isRunning() ? { facts: "run paused" } : {}), options }, ctx.isRunning() ? "\u276f run paused" : `\u25b8 ${mode}`), (v) => {
637
+ if (v.action === "picked")
638
+ level = v.level;
639
+ resolve(v.action === "picked" ? v.result : null);
640
+ });
641
+ });
642
+ ctx.paintIdle();
643
+ const f = picked !== null && "index" in picked ? order[picked.index] : undefined;
644
+ if (f === undefined) {
645
+ ctx.input.prompt();
646
+ return; // esc — nothing changed, nothing said
647
+ }
648
+ if (f.name === "mode") {
649
+ const chosen = level === undefined ? undefined : OFFERED_MODES[level];
650
+ if (chosen !== undefined && chosen !== mode) {
651
+ const wasOn = getDontAsk();
652
+ applyModeSetting({ mode: chosen });
653
+ body.modeNotice(`mode \u2192 ${MODE_LABEL[chosen]}`, MODE_LABEL[mode], MODE_LABEL[chosen]);
654
+ if (getDontAsk() !== wasOn)
655
+ body.dontAskNotice(`don't ask \u2192 ${getDontAsk() ? "on" : "off"}`, getDontAsk());
656
+ ctx.paintIdle();
657
+ }
658
+ ctx.input.prompt();
659
+ return;
660
+ }
661
+ if (f.name === "don't ask") {
662
+ // /dont-ask's own path: the switch, its row, and nothing written
663
+ const want = level === undefined ? undefined : DONT_ASK[level] === "on";
664
+ if (want !== undefined && want !== getDontAsk()) {
665
+ setDontAsk(want);
666
+ body.dontAskNotice(`don't ask \u2192 ${want ? "on" : "off"}`, want);
667
+ ctx.paintIdle();
668
+ }
669
+ ctx.input.prompt();
670
+ return;
671
+ }
672
+ if (f.name === "thinking") {
673
+ const want = level === undefined ? undefined : THINK[level];
674
+ if (want !== undefined && (want === "hidden") !== hidden) {
675
+ dispatch("\x14think", ctx); // ctrl+t's own path: the toggle, remembered
676
+ return;
677
+ }
678
+ ctx.input.prompt();
679
+ return;
680
+ }
681
+ if (f.name === "model") {
682
+ dispatch("/model", ctx); // the picker's own segment re-arms the prompt
683
+ return;
684
+ }
685
+ bodyLog(settingRow(f), "words");
686
+ ctx.input.prompt();
687
+ return;
688
+ }
689
+ bodyLog(settingsRows(input).join("\n\n"), "words");
690
+ ctx.input.prompt();
691
+ });
692
+ return;
693
+ }
694
+ if (trimmed === "/name" || trimmed.startsWith("/name ")) {
695
+ // Graphite R3d: the person names the session. The name lives in the
696
+ // session's sidecar (the runtime's `name` tenant) — the terminal
697
+ // title, the /resume row and the session list read it, the title
698
+ // derived from the first real line is the fallback. Control and
699
+ // format characters never reach it; it is cut at 60 like a title.
700
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
701
+ const words = trimmed.slice("/name".length).replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
702
+ const root = activeStoreDir;
703
+ if (words === "") {
704
+ const named = readSessionName(root, ctx.session.id);
705
+ const derived = sessionTitle(ctx.session.log.all.map((event) => ({ runId: "", ts: 0, event })));
706
+ body.notice(named !== null ? `name: ${escapeTerminal(named)} \u00b7 /name - clears it` : `unnamed \u2014 /resume lists it as "${escapeTerminal(derived)}" \u00b7 /name <words> names it`);
707
+ }
708
+ else if (words === "-") {
709
+ writeSessionName(root, ctx.session.id, null);
710
+ setTitleName(null);
711
+ // the tasks round: the tab says kiso since the card round, so what the
712
+ // first line names again is the session in /resume
713
+ body.notice("name cleared \u2014 /resume lists it by its first line again");
714
+ }
715
+ else {
716
+ const name = [...words].slice(0, NAME_MAX).join("");
717
+ writeSessionName(root, ctx.session.id, name);
718
+ setTitleName(name);
719
+ body.notice(`named "${escapeTerminal(name)}"`);
720
+ }
535
721
  ctx.input.prompt();
536
722
  });
537
723
  return;
@@ -543,6 +729,28 @@ export function dispatch(line, ctx) {
543
729
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
544
730
  const ctxRatio = ctx.estimateCtx();
545
731
  const ctxPct = Number.isFinite(ctxRatio) ? `~${Math.round(ctxRatio * 100)}%` : "~?";
732
+ // Graphite R3e: on a dock /status is a sheet over the input — the
733
+ // same facts, one per row, read and then typed past; off one (a
734
+ // pipe, -p) the lines below are what it always printed
735
+ if (dock.active && ctx.input.openSheet !== undefined) {
736
+ const w = statedContextWindow();
737
+ const named = readSessionName(activeStoreDir, ctx.session.id);
738
+ const win = ctx.contextWindow();
739
+ const tiers = win > 0 ? tiersFor(win, 0) : null;
740
+ const pal = palette();
741
+ const profileOf = currentProfileName === null ? "" : ` · profile ${currentProfileName}`;
742
+ const facts = [
743
+ { label: "session", value: `${ctx.session.id}${named === null ? "" : ` · ${named}`} · ${ctx.session.log.all.length} events` },
744
+ { label: "model", value: `${agentModel}${providerLabel(agentBaseUrl, upstreamOf(agentBaseUrl))}${profileOf}` },
745
+ { label: "context", value: `${ctxPct} used · ${windowSourceNote(w)}` },
746
+ ...(tiers === null ? [] : [{ label: "compaction", value: `past ${Math.round((tiers.soft / win) * 100)}% at a phase end, past ${Math.round((tiers.hard / win) * 100)}% at once · /context for the split` }]),
747
+ { label: "colour", value: `${pal.tier === null ? "off" : pal.tier === "24bit" ? "24-bit" : "256"} · ground ${currentGround()}` },
748
+ { label: "version", value: versionStatusLine(installedVersion(), VERSION).replace(/^version /, "") },
749
+ ];
750
+ ctx.input.openSheet((W) => infoSheetRows("status", facts, W));
751
+ ctx.input.prompt();
752
+ return;
753
+ }
546
754
  bodyLog(`session ${ctx.session.id}`);
547
755
  bodyLog(`${ctx.session.log.all.length} events`);
548
756
  // CW-1: the percentage names its denominator and who stated it — a
@@ -580,11 +788,15 @@ export function dispatch(line, ctx) {
580
788
  // a tier switch, old names included — and when the switch came
581
789
  // with the old dontAsk tier and leaves with it, a line says so
582
790
  const switchTier = (s) => {
791
+ const was = getMode();
583
792
  const wasOn = getDontAsk();
584
793
  applyModeSetting(s);
585
- body.notice(`mode → ${MODE_LABEL[getMode()]}`);
794
+ // Graphite R3e / the main-sync round: the MODE row, and the DON'T
795
+ // ASK row when the switch moved with it, on a terminal; a pipe
796
+ // prints these words as they are
797
+ body.modeNotice(`mode → ${MODE_LABEL[getMode()]}`, MODE_LABEL[was], MODE_LABEL[getMode()]);
586
798
  if (getDontAsk() !== wasOn)
587
- body.notice(`don't ask → ${getDontAsk() ? "on" : "off"}`);
799
+ body.dontAskNotice(`don't ask → ${getDontAsk() ? "on" : "off"}`, getDontAsk());
588
800
  ctx.paintIdle();
589
801
  };
590
802
  if (arg === "") {
@@ -604,8 +816,16 @@ export function dispatch(line, ctx) {
604
816
  // the header names the switch when it is on; the
605
817
  // rows are the tiers alone — don't ask is not a tier
606
818
  // and is not offered beside them (owner, 2026-09-30)
607
- header: `mode — current: ${modeDisplay()}`,
608
- options: OFFERED_MODES.map((name) => ({ label: MODE_LABEL[name], note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
819
+ // Graphite P3: the current tier rides the band's name, in
820
+ // the owner's words of 2026-09-30 (`current: full access ·
821
+ // don't ask`) — the row that said it is gone
822
+ header: "mode",
823
+ facts: `current: ${modeDisplay()}${ctx.isRunning() ? " \u00b7 run paused" : ""}`,
824
+ enter: "\u23ce switches",
825
+ // the main-sync design: no `· current` suffix — the cursor
826
+ // opens on the current tier and the band names it; at 80
827
+ // columns the suffix was cut to `· c`
828
+ options: OFFERED_MODES.map((name) => ({ label: MODE_LABEL[name], note: MODE_NOTE[name] })),
609
829
  // MP-1 (0.40.7): the cursor opens on the tier in force
610
830
  ...(OFFERED_MODES.indexOf(current) >= 0 ? { initial: OFFERED_MODES.indexOf(current) } : {}),
611
831
  },
@@ -619,8 +839,7 @@ export function dispatch(line, ctx) {
619
839
  }
620
840
  const chosen = "index" in picked ? (OFFERED_MODES[picked.index] === undefined ? undefined : { mode: OFFERED_MODES[picked.index] }) : parseMode(picked.custom);
621
841
  if (chosen === undefined) {
622
- bodyLog(`no such mode: ${"index" in picked ? String(picked.index) : picked.custom.trim()}`);
623
- bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
842
+ body.notice(`no such mode: ${"index" in picked ? String(picked.index) : picked.custom.trim()}\ntiers: ${OFFERED_MODES.join(" ")}`);
624
843
  }
625
844
  else {
626
845
  switchTier(chosen);
@@ -636,8 +855,7 @@ export function dispatch(line, ctx) {
636
855
  else {
637
856
  const typed = parseMode(arg);
638
857
  if (typed === undefined) {
639
- bodyLog(`no such mode: ${arg}`);
640
- bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
858
+ body.notice(`no such mode: ${arg}\ntiers: ${OFFERED_MODES.join(" ")}`);
641
859
  }
642
860
  else {
643
861
  switchTier(typed);
@@ -657,8 +875,12 @@ export function dispatch(line, ctx) {
657
875
  bodyLog("usage: /dont-ask [on|off]");
658
876
  }
659
877
  else {
878
+ const wasOn = getDontAsk();
660
879
  setDontAsk(want);
661
- body.notice(`don't ask → ${want ? "on" : "off"}`);
880
+ if (want !== wasOn)
881
+ body.dontAskNotice(`don't ask → ${want ? "on" : "off"}`, want);
882
+ else
883
+ body.notice(`don't ask → ${want ? "on" : "off"}`);
662
884
  ctx.paintIdle();
663
885
  }
664
886
  ctx.input.prompt();
@@ -686,36 +908,43 @@ export function dispatch(line, ctx) {
686
908
  let pickedLevel;
687
909
  const picked = await new Promise((resolve) => {
688
910
  ctx.input.panelAsk(modelPickView({
689
- header: `model — current: ${agentModel}`,
911
+ // Graphite P3 (owner, 2026-10-04): the band counts the
912
+ // profiles and the rows are a table — the model, its host,
913
+ // and a state word only where there is one (`current`, or
914
+ // why it cannot run). Typing FILTERS, as /resume does: the
915
+ // model, then the host, then the profile's name; a typed
916
+ // `provider/model` that no profile is becomes a row of its
917
+ // own — DECLARED REVERSAL of the `t` row (TUI2-R2 ④) and of
918
+ // the digits here (a digit is a letter of a model name).
919
+ header: "model",
920
+ noun: "profiles",
921
+ ...(ctx.isRunning() ? { facts: "run paused" } : {}),
922
+ input: "filter",
923
+ // PH-1a (finding PH-F4): the example is a syntax
924
+ // directWriteProfile ACCEPTS — the fresh install sees it
925
+ filterHint: names.length === 0 ? "type provider/model (e.g. openai-compat/deepseek-reasoner)" : "filter, or type provider/model",
926
+ direct: true,
927
+ enter: "\u23ce switches",
690
928
  options: names.map((name) => {
691
929
  const profile = configModels[name];
692
- // the note keeps what tells two rows apart; the levels
693
- // left it for the axis below (they were being cut off
694
- // the end of the note column at 100 columns).
695
- // The owner, 2026-09-21: two profiles can name ONE model
696
- // id and reach two accounts, so the current mark follows
697
- // the PROFILE the session is on (`currentModelName`), not
698
- // the model id — the id alone marked both such rows, or
699
- // neither. The row's own provider rides the label, so two
700
- // rows that share an id still read differently.
701
- // 0.40.1 (the owner's dogfood, 2026-09-21): the `profile: <key>` prefix
702
- // is gone — the row already names the model and its endpoint, and the
703
- // profile key is what `/model <name>` takes, not what a chooser
704
- // reading a list needs. What remains is what the chooser CANNOT
705
- // infer: availability, and which one is live.
706
- // 0.40.6 (the owner, 2026-09-23: "who told you to write ctx"):
707
- // the window rode here in 0.40.3–0.40.5 and cut `unavailable`
708
- // to `unavailabl…` on the owner's rows. It lives on /status and
709
- // /settings, where there is room to say it and its source.
710
- const marks = [...(profileAvailable(profile) ? [] : ["unavailable"]), ...(name === currentProfileName ? ["current"] : [])];
711
- const host = profileProviderLabel(profile.kind, profile.baseUrl, profile.upstream);
712
- return { label: `${profile.kind}/${profile.model}${host === "" ? "" : ` ${host}`}`, note: marks.join(" · "), ...effortAxis(profile) };
930
+ const available = profileAvailable(profile);
931
+ // 0.40.1 took the `profile: <key>` prefix off every row and
932
+ // the kind/ prefix rode the label; P3 moves both to the
933
+ // selected row's opened line (owner: the profile name kept
934
+ // there), beside what signs it in. The owner, 2026-09-21:
935
+ // two profiles can name ONE model id, so `current` follows
936
+ // the PROFILE, not the id.
937
+ const host = profileProviderLabel(profile.kind, profile.baseUrl, profile.upstream).replace(/^@/, "");
938
+ const state = name === currentProfileName ? "current" : available ? "" : profile.apiKeyEnv === undefined ? "sign in" : "no key";
939
+ return {
940
+ label: profile.model,
941
+ cols: [host, state],
942
+ opened: available ? `profile ${name} \u00b7 ${profile.kind} \u00b7 ${signInNote(profile)}` : unavailableWhy(name, profile),
943
+ ...(available ? {} : { off: true }),
944
+ match: [name],
945
+ ...effortAxis(profile),
946
+ };
713
947
  }),
714
- // PH-1a (finding PH-F4): the example must be a syntax
715
- // directWriteProfile actually ACCEPTS — the old
716
- // "openai/…" hint failed with "no such model profile"
717
- // on exactly the fresh-install path that shows it.
718
- typeHint: names.length === 0 ? "type provider/model directly (e.g. openai-compat/deepseek-reasoner)" : "type provider/model directly",
719
948
  // MP-1 (0.40.7): the cursor opens on the session's own
720
949
  // profile — opening on row 0 plus one Enter changed who pays
721
950
  ...(currentProfileName !== null && names.indexOf(currentProfileName) >= 0 ? { initial: names.indexOf(currentProfileName) } : {}),
@@ -777,7 +1006,7 @@ export function dispatch(line, ctx) {
777
1006
  const direct = directWriteProfile(profName);
778
1007
  const profile = direct ?? configModels[profName];
779
1008
  if (profile === undefined) {
780
- bodyLog(`no such model profile: ${profName}`);
1009
+ body.notice(`no such model profile: ${profName}`);
781
1010
  }
782
1011
  else if (!profileAvailable(profile)) {
783
1012
  // the PROFILE name, not the whole argument: what is
@@ -912,6 +1141,10 @@ export function dispatch(line, ctx) {
912
1141
  // model summary — an OFF-LOOP call through the session's own
913
1142
  // adapter, so it must never race a running turn: refused
914
1143
  // mid-run, with a hint to wait for the turn to end.
1144
+ // Graphite §8.7 (R3b): the row says why — the person asked, or the
1145
+ // opt-in threshold did (`markAutoCompact`, read once here)
1146
+ const compactWhy = nextCompactWhy ?? "manual";
1147
+ nextCompactWhy = null;
915
1148
  if (ctx.isRunning()) {
916
1149
  body.notice("[/compact] a turn is running — wait for it to finish");
917
1150
  return;
@@ -953,11 +1186,16 @@ export function dispatch(line, ctx) {
953
1186
  const compacting = (info) => {
954
1187
  // 0.40.0: composed by the row seam, not a template here — the row
955
1188
  // is where the launch build's progress segment plugs in.
956
- const text = (elapsed) => compactingStatus(glyph, info.rounds, info.tokens, elapsed, undefined, retryOnRow(), progress);
957
- repaintCompacting = () => dock.setStatus(text(Math.round((Date.now() - compactStart) / 1000)), "esc to cancel");
1189
+ // Graphite §8.7: compacting is a LIVE state — its row rides above
1190
+ // the composer, the status bar below keeps the session. Composed
1191
+ // against the width the row has (it used to be composed blind).
1192
+ const W = () => (process.stdout.columns > 0 ? process.stdout.columns : 80);
1193
+ const text = (elapsed) => liveRow(compactingStatus(glyph, info.rounds, info.tokens, elapsed, W() - 20, retryOnRow(), progress, compactWhy), ["esc to cancel"], W());
1194
+ repaintCompacting = () => dock.setLive(text(Math.round((Date.now() - compactStart) / 1000)));
958
1195
  compactStart = Date.now();
959
1196
  ctxBefore = ctxPercent(ctx.estimateCtx());
960
- dock.setStatus(text(0), "esc to cancel");
1197
+ dock.setLive(text(0));
1198
+ setTitleState("working"); // §8.10; the idle paint after it says ready again
961
1199
  stopSpinner = startStatusSpinner((next) => {
962
1200
  glyph = next;
963
1201
  repaintCompacting();
@@ -1020,8 +1258,12 @@ export function dispatch(line, ctx) {
1020
1258
  // here: the line a resumed or replayed view would draw is
1021
1259
  // the line drawn now, by construction, and the row cannot
1022
1260
  // drift into two spellings of one fact.
1261
+ //
1262
+ // 0.47.1 (finding 0470-F2, owner 2026-10-07): on a dock the
1263
+ // COMPACTED row above already says it, as the in-run path's
1264
+ // does (§7.12) — the raw row under it is drawn on a pipe only.
1023
1265
  const boundary = renderEvent({ type: "summarized", coversToSeq: result.coversToSeq });
1024
- if (boundary.text !== "")
1266
+ if (boundary.text !== "" && !dock.active)
1025
1267
  body.raw(boundary.text.replace(/\n$/, "").split("\n"));
1026
1268
  }
1027
1269
  }
@@ -1153,12 +1395,12 @@ export function dispatch(line, ctx) {
1153
1395
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1154
1396
  const route = ctx.route?.(arg) ?? null;
1155
1397
  if (route === null || route.kind === "refused") {
1156
- bodyLog(route?.line ?? `no such session: ${escapeTerminal(arg)} — /resume lists them`);
1398
+ body.notice(route?.line ?? `no such session: ${escapeTerminal(arg)} — /resume lists them`);
1157
1399
  ctx.input.prompt();
1158
1400
  return;
1159
1401
  }
1160
1402
  if (route.kind === "elsewhere" && route.line !== null)
1161
- bodyLog(route.line);
1403
+ body.notice(route.line);
1162
1404
  ctx.requestSwitch(arg);
1163
1405
  });
1164
1406
  return;
@@ -1169,7 +1411,7 @@ export function dispatch(line, ctx) {
1169
1411
  const others = ctx.sessions().filter((id) => id !== ctx.session.id);
1170
1412
  if (others.length === 0) {
1171
1413
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1172
- bodyLog("no other sessions — /clear starts a fresh one");
1414
+ body.notice("no other sessions — /clear starts a fresh one");
1173
1415
  ctx.input.prompt();
1174
1416
  });
1175
1417
  return;
@@ -1185,12 +1427,12 @@ export function dispatch(line, ctx) {
1185
1427
  // session is refused with where to go
1186
1428
  const route = ctx.sessions().includes(picked) ? null : (ctx.route?.(picked) ?? null);
1187
1429
  if (route?.kind === "refused") {
1188
- bodyLog(route.line);
1430
+ body.notice(route.line);
1189
1431
  ctx.input.prompt();
1190
1432
  return;
1191
1433
  }
1192
1434
  if (route?.kind === "elsewhere" && route.line !== null)
1193
- bodyLog(route.line);
1435
+ body.notice(route.line);
1194
1436
  ctx.requestSwitch(picked);
1195
1437
  });
1196
1438
  return;
@@ -1214,10 +1456,17 @@ export function dispatch(line, ctx) {
1214
1456
  return;
1215
1457
  }
1216
1458
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1217
- if (skill?.kind === "list")
1459
+ // the sheets round: on a dock /skills is a sheet over the input
1460
+ // (§8.16); off one it prints the lines it always printed
1461
+ if (skill?.kind === "list" && dock.active && ctx.input.openSheet !== undefined) {
1462
+ const catalog = loadedSkillsCatalog();
1463
+ const userDir = tildePath(join(kisoHome(), "skills"));
1464
+ ctx.input.openSheet((W) => skillsSheetRows(catalog, skillSource, userDir, W));
1465
+ }
1466
+ else if (skill?.kind === "list")
1218
1467
  bodyLog(skillsRows(loadedSkillsCatalog(), skillSource, tildePath(join(kisoHome(), "skills"))).map(escapeTerminal).join("\n"));
1219
1468
  else if (skill?.kind === "error")
1220
- bodyLog(escapeTerminal(skill.message));
1469
+ body.notice(escapeTerminal(skill.message));
1221
1470
  ctx.input.prompt();
1222
1471
  });
1223
1472
  return;
@@ -1241,7 +1490,7 @@ export function dispatch(line, ctx) {
1241
1490
  // reading when they typed it.
1242
1491
  const word = trimmed.split(/\s+/)[0] ?? trimmed;
1243
1492
  const known = slashCommandNames().includes(word);
1244
- bodyLog(known
1493
+ body.notice(known
1245
1494
  ? `${escapeTerminal(word)} takes no arguments — /help says what it does`
1246
1495
  : `unknown command: ${escapeTerminal(word)} — /help lists the commands`);
1247
1496
  ctx.input.prompt();
@@ -1260,7 +1509,7 @@ export function dispatch(line, ctx) {
1260
1509
  async function tasksCommand(arg, ctx) {
1261
1510
  const manager = tasksFor(ctx.session.id);
1262
1511
  if (manager === undefined) {
1263
- bodyLog("no tasks here — this session has no task manager");
1512
+ body.notice("no tasks here — this session has no task manager");
1264
1513
  return;
1265
1514
  }
1266
1515
  let list;
@@ -1268,10 +1517,18 @@ async function tasksCommand(arg, ctx) {
1268
1517
  list = manager.list();
1269
1518
  }
1270
1519
  catch (err) {
1271
- bodyLog(`tasks: ${err.message}`);
1520
+ body.notice(`tasks: ${err.message}`);
1272
1521
  return;
1273
1522
  }
1274
1523
  const show = (t) => {
1524
+ // the tasks round (owner, 2026-10-06): on a dock a task's output is a
1525
+ // sheet over the input (§8.16) — the newest lines a screen can hold
1526
+ // beside the composer; off one, the lines it always printed
1527
+ if (dock.active && ctx.input.openSheet !== undefined) {
1528
+ const lines = taskOutput(t, 12);
1529
+ ctx.input.openSheet((W) => taskOutputSheetRows(t, lines, W));
1530
+ return;
1531
+ }
1275
1532
  const lines = taskOutput(t);
1276
1533
  // Amendment 8: a lost task says why, before its output
1277
1534
  const why = lostReason(t);
@@ -1282,13 +1539,13 @@ async function tasksCommand(arg, ctx) {
1282
1539
  bodyLog(` ${line}`);
1283
1540
  };
1284
1541
  const stop = (t) => {
1285
- bodyLog(manager.stop(t.id, "person") ? `${t.id}: stop requested` : `${t.id}: nothing to stop (${taskStateLabel(t)})`);
1542
+ body.notice(manager.stop(t.id, "person") ? `${t.id}: stop requested` : `${t.id}: nothing to stop (${taskStateLabel(t)})`);
1286
1543
  };
1287
1544
  const [verb, id] = arg.split(/\s+/);
1288
1545
  if (verb === "show" || verb === "stop") {
1289
1546
  const t = list.find((x) => x.id === id);
1290
1547
  if (t === undefined)
1291
- bodyLog(`no task ${id ?? ""} in this session`);
1548
+ body.notice(`no task ${id ?? ""} in this session`);
1292
1549
  else if (verb === "show")
1293
1550
  show(t);
1294
1551
  else
@@ -1296,11 +1553,11 @@ async function tasksCommand(arg, ctx) {
1296
1553
  return;
1297
1554
  }
1298
1555
  if (arg !== "") {
1299
- bodyLog("usage: /tasks · /tasks show <id> · /tasks stop <id>");
1556
+ body.notice("usage: /tasks · /tasks show <id> · /tasks stop <id>");
1300
1557
  return;
1301
1558
  }
1302
1559
  if (list.length === 0) {
1303
- bodyLog("no tasks in this session");
1560
+ body.notice("no tasks in this session");
1304
1561
  return;
1305
1562
  }
1306
1563
  if (!dock.active) {