@corenel/term 0.2.8 → 0.3.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.
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  emptyState,
3
3
  parseTodos
4
- } from "./chunk-L4KCEKIT.js";
4
+ } from "./chunk-2MGDL3I4.js";
5
5
  import {
6
- parseCommand
7
- } from "./chunk-VTJEWHM3.js";
6
+ formatHelpText,
7
+ isKnownCommandWord,
8
+ parseCommand,
9
+ unknownCommandMessage
10
+ } from "./chunk-5RPHYXND.js";
8
11
  import {
9
12
  WRITE_TOOL_NAMES,
10
13
  buildWritePreview
@@ -24,6 +27,76 @@ import {
24
27
  import { runAgent } from "@corenel/harness/core/loop";
25
28
  import { priceUsageWith } from "@corenel/harness/core/pricing";
26
29
 
30
+ // src/mcpText.ts
31
+ function mcpStatusWord(v) {
32
+ if (v.error) return "unreachable";
33
+ if (!v.signedIn) return "sign-in needed";
34
+ if (v.connected) return "connected";
35
+ return "cached";
36
+ }
37
+ function ageWord(fetchedAt, now) {
38
+ if (fetchedAt === null) return "never fetched";
39
+ const ms = Math.max(0, now - fetchedAt);
40
+ const mins = Math.floor(ms / 6e4);
41
+ if (mins < 1) return "fetched just now";
42
+ if (mins < 60) return `fetched ${mins}m ago`;
43
+ const hours = Math.floor(mins / 60);
44
+ if (hours < 24) return `fetched ${hours}h ago`;
45
+ const days = Math.floor(hours / 24);
46
+ return `fetched ${days}d ago`;
47
+ }
48
+ function formatMcpServers(views, now) {
49
+ if (views.length === 0) {
50
+ return "No mcp servers configured. /mcp add <name> <url> to add one.";
51
+ }
52
+ const nameWidth = Math.max(...views.map((v) => v.name.length));
53
+ const statusWidth = Math.max(...views.map((v) => mcpStatusWord(v).length));
54
+ const lines = views.map((v) => {
55
+ const tools = `${v.toolCount} tool${v.toolCount === 1 ? "" : "s"}`;
56
+ const hosted = v.hosted ? " hosted" : "";
57
+ const error = v.error ? ` (${v.error})` : "";
58
+ return ` ${v.name.padEnd(nameWidth)} ${mcpStatusWord(v).padEnd(statusWidth)} ${tools}, ${ageWord(v.fetchedAt, now)}${hosted}${error}`;
59
+ });
60
+ return [
61
+ `${views.length} mcp server${views.length === 1 ? "" : "s"} configured:`,
62
+ ...lines
63
+ ].join("\n");
64
+ }
65
+
66
+ // src/contextText.ts
67
+ function fmtBytes(bytes) {
68
+ return `${Math.round(bytes / 1024)} KB`;
69
+ }
70
+ function formatContext(s) {
71
+ const lines = [];
72
+ if (s.sentTokens === null) {
73
+ lines.push("no model call has completed yet this conversation -- nothing sent to size.");
74
+ } else if (s.contextWindow === null) {
75
+ lines.push(`${s.sentTokens} tokens sent on the last model call -- context window unknown for this model.`);
76
+ } else {
77
+ const pct = Math.round(s.sentTokens / s.contextWindow * 100);
78
+ lines.push(`${s.sentTokens} / ${s.contextWindow} tokens (${pct}%) sent on the last model call.`);
79
+ }
80
+ lines.push("");
81
+ const loaded = s.loadedToolNames.length ? `, ${s.loadedToolNames.length} MCP tool${s.loadedToolNames.length === 1 ? "" : "s"} loaded (${s.loadedToolNames.join(", ")})` : "";
82
+ lines.push(`tools: ${s.toolCount} available${loaded}`);
83
+ lines.push("");
84
+ if (s.offloaded.length === 0) {
85
+ lines.push("offloaded: none");
86
+ } else {
87
+ lines.push(`offloaded (${s.offloaded.length}, recall with /expand):`);
88
+ for (const o of s.offloaded) lines.push(` ${o.name} ${fmtBytes(o.bytes)}`);
89
+ }
90
+ lines.push("");
91
+ if (s.checkpoints.length === 0) {
92
+ lines.push("compaction: none this conversation");
93
+ } else {
94
+ lines.push(`compaction seams (${s.checkpoints.length}):`);
95
+ for (const c of s.checkpoints) lines.push(` ${c.collapsed} messages collapsed into a checkpoint`);
96
+ }
97
+ return lines.join("\n");
98
+ }
99
+
27
100
  // src/markdown.ts
28
101
  var FENCE = /^```(\w*)\s*$/;
29
102
  var HEADING = /^(#{1,6})\s+(.*)$/;
@@ -83,34 +156,14 @@ function stripInline(s) {
83
156
  }
84
157
 
85
158
  // src/session.ts
86
- var HELP_TEXT = [
87
- "Type a request and press enter.",
88
- "",
89
- " /tools what the agent can do, and what it can change",
90
- " /policy how this session is governed -- levels, caps, and what actually confines it",
91
- " /expand #3 re-print a tool result in full (#3 is the id on its row)",
92
- " /resume [id] reopen an earlier conversation",
93
- " /clear forget the conversation",
94
- " /exit leave",
95
- "",
96
- " /mode [id] switch the agent mode, or list what is configured",
97
- " /persona [id] switch persona, or list what is configured",
98
- " /skill [name] apply a skill to your NEXT message only, or list what is installed",
99
- " /strategy [name] run a saved strategy from .prompd/strategies, or list what is saved",
100
- "",
101
- /* Deliberately not 'esc stops a turn': this text is shared, and Escape is
102
- * an Ink-only key -- the readline fallback has no key handler at all, so
103
- * there Ctrl-C is the only way to stop a turn. Name the one that is true
104
- * on both faces, and mark the other as what it is. */
105
- "Ctrl-C stops a turn; twice leaves. Ctrl-D leaves. In the full terminal face, esc also stops a turn."
106
- ].join("\n");
107
159
  var DELTA_FLUSH_MS = 50;
108
160
  var TOOL_DELTA_CAP = 4e3;
109
161
  var TermSession = class {
110
162
  constructor(deps) {
111
163
  this.deps = deps;
164
+ this.state = emptyState(deps.model);
112
165
  }
113
- state = emptyState();
166
+ state;
114
167
  listeners = /* @__PURE__ */ new Set();
115
168
  nextOrd = 1;
116
169
  idSeq = 0;
@@ -176,6 +229,13 @@ var TermSession = class {
176
229
  * reach a renderer. */
177
230
  pendingSkill = null;
178
231
  wantsExit = false;
232
+ /** The last line that was WARNED ABOUT rather than sent, because its first
233
+ * word after `/` looked like a command but wasn't one -- see `submit`. Set
234
+ * on the warn, cleared the moment anything else happens (a different line,
235
+ * a real command, an empty line): only an EXACT resubmission of this same
236
+ * text counts as "I meant it, send it anyway". `null` means nothing is
237
+ * pending. */
238
+ lastRejectedLine = null;
179
239
  attachPermission(p) {
180
240
  this.permission = p;
181
241
  }
@@ -428,6 +488,11 @@ var TermSession = class {
428
488
  }
429
489
  case "offload":
430
490
  this.pendingOffload = { refId: e.id, bytes: e.bytes };
491
+ this.set({ offloaded: [...this.state.offloaded, { id: e.id, name: e.name, bytes: e.bytes }] });
492
+ break;
493
+ case "checkpoint":
494
+ this.note("info", `checkpoint ${e.collapsed} messages collapsed into CHECKPOINT.md`);
495
+ this.set({ checkpoints: [...this.state.checkpoints, { collapsed: e.collapsed, checkpoint: e.checkpoint }] });
431
496
  break;
432
497
  case "permission":
433
498
  case "permission-audit":
@@ -459,9 +524,18 @@ var TermSession = class {
459
524
  * rates, and costUsd stays 0, which the face reads as "no cost to
460
525
  * show". Rates are read per event, not captured once, because the
461
526
  * host may still be hydrating them when the first turn runs. */
462
- costUsd: this.state.costUsd + (priceUsageWith(e.usage, this.deps.rates?.() ?? null) ?? 0)
527
+ costUsd: this.state.costUsd + (priceUsageWith(e.usage, this.deps.rates?.() ?? null) ?? 0),
528
+ // `/context`'s fill answer: OVERWRITTEN, not accumulated like
529
+ // `usage`/`turnUsage` above -- this is "what's in the window right
530
+ // now" (the last call's prompt size), not a running total. The loop
531
+ // emits one `usage` event per model call, so the LAST one to land
532
+ // is always the most recent call, tool round-trips included.
533
+ sentTokens: e.usage.promptTokens
463
534
  });
464
535
  break;
536
+ case "tools-loaded":
537
+ this.set({ toolNames: e.names });
538
+ break;
465
539
  case "artifact":
466
540
  this.note("info", `artifact ${e.kind}${e.title ? ` ${e.title}` : ""}`);
467
541
  break;
@@ -481,15 +555,63 @@ var TermSession = class {
481
555
  break;
482
556
  }
483
557
  }
484
- /** Route one line: a command runs here; anything else is a turn. */
558
+ /** Whether calling `submit(text)` right now would warn-and-refuse rather
559
+ * than run a command or send a message -- i.e. `text`'s first word after a
560
+ * leading `/` looks like a command attempt but isn't one, AND this isn't
561
+ * already the resend of a line that was just warned about. PURE: does not
562
+ * touch `lastRejectedLine` or commit anything, so a caller can check it
563
+ * before deciding what to do with the input box (see ink/index.tsx's
564
+ * onSubmit, which must NOT clear the box for the line this reports true
565
+ * for) without racing `submit`'s own mutation of the same state. */
566
+ willReject(text) {
567
+ const line = text.trim();
568
+ if (!line || !line.startsWith("/")) return false;
569
+ if (parseCommand(line)) return false;
570
+ const word = line.slice(1).split(/\s+/)[0] ?? "";
571
+ if (!word || isKnownCommandWord(word)) return false;
572
+ return this.lastRejectedLine !== line;
573
+ }
574
+ /** Route one line: a command runs here; anything else is a turn -- UNLESS
575
+ * the first word after a leading `/` looks like a command attempt and
576
+ * isn't one (see commands.ts's `isKnownCommandWord`), in which case the
577
+ * FIRST Enter on that exact text warns and stops rather than paying for a
578
+ * model call on a typo (`/context` before Feature 2 added it, `/toosl`,
579
+ * `/hepl`, ...). A SECOND Enter on the identical, unchanged line is taken
580
+ * as "I meant it" and sent as an ordinary message -- tracked in
581
+ * `lastRejectedLine`, which both faces share automatically since it lives
582
+ * here rather than in either face's own state: the Ink face keeps the text
583
+ * in the input box (ink/index.tsx checks `willReject` before clearing it)
584
+ * so retyping Enter resubmits the SAME string; the readline face has no
585
+ * input box to hold onto, so there the "second Enter" is the user
586
+ * retyping the line themselves -- an honest equivalent, not a shortcut. */
485
587
  async submit(text) {
486
588
  const line = text.trim();
487
- if (!line) return;
589
+ if (!line) {
590
+ this.lastRejectedLine = null;
591
+ return;
592
+ }
488
593
  const cmd = parseCommand(line);
489
594
  if (cmd) {
595
+ this.lastRejectedLine = null;
490
596
  await this.runCommand(cmd);
491
597
  return;
492
598
  }
599
+ if (line.startsWith("/")) {
600
+ const word = line.slice(1).split(/\s+/)[0] ?? "";
601
+ if (word && !isKnownCommandWord(word)) {
602
+ if (this.lastRejectedLine === line) {
603
+ this.lastRejectedLine = null;
604
+ } else {
605
+ this.lastRejectedLine = line;
606
+ this.note("warn", unknownCommandMessage(word));
607
+ return;
608
+ }
609
+ } else {
610
+ this.lastRejectedLine = null;
611
+ }
612
+ } else {
613
+ this.lastRejectedLine = null;
614
+ }
493
615
  await this.queueOrRun(line);
494
616
  }
495
617
  /** Run `line` as a turn now, or queue it, exactly like `submit` does for
@@ -522,7 +644,15 @@ var TermSession = class {
522
644
  // conversation's cost to this one.
523
645
  costUsd: 0,
524
646
  todos: [],
525
- pendingSkill: null
647
+ pendingSkill: null,
648
+ // /context's accumulators are per-conversation facts (what was
649
+ // sent, what got offloaded, what got compacted) -- same reasoning
650
+ // as costUsd just above. `toolNames` (the MCP set) is NOT reset
651
+ // here: it describes what is currently LOADED, independent of the
652
+ // conversation that gets forgotten.
653
+ sentTokens: null,
654
+ offloaded: [],
655
+ checkpoints: []
526
656
  });
527
657
  if (droppedSkill) this.note("info", `cleared -- the armed skill "${droppedSkill.label}" was dropped too`);
528
658
  return;
@@ -537,8 +667,11 @@ var TermSession = class {
537
667
  }
538
668
  this.note("info", formatPolicy(this.deps.policy, this.deps.policySource, this.deps.tools));
539
669
  return;
670
+ case "context":
671
+ this.note("info", formatContext(this.contextSnapshot()));
672
+ return;
540
673
  case "help":
541
- this.note("info", HELP_TEXT);
674
+ this.note("info", formatHelpText());
542
675
  return;
543
676
  case "expand": {
544
677
  if (cmd.ord === void 0) {
@@ -551,6 +684,9 @@ var TermSession = class {
551
684
  case "resume":
552
685
  await this.resume(cmd.id);
553
686
  return;
687
+ case "model":
688
+ await this.runModelCommand(cmd.id);
689
+ return;
554
690
  case "mode":
555
691
  await this.switchIdentity({
556
692
  kind: "mode",
@@ -573,6 +709,9 @@ var TermSession = class {
573
709
  case "strategy":
574
710
  await this.runStrategyCommand(cmd.id);
575
711
  return;
712
+ case "mcp":
713
+ await this.runMcpCommand(cmd.sub, cmd.args ?? [], cmd.unknown);
714
+ return;
576
715
  }
577
716
  }
578
717
  /** Runs a host-supplied config-workspace call (`listModes`, `listSkills`,
@@ -597,11 +736,17 @@ var TermSession = class {
597
736
  return { ok: false };
598
737
  }
599
738
  }
600
- /** Shared `/mode` and `/persona` shape: no id lists what's configured, an
739
+ /** Shared `/mode`/`/persona` shape: no id lists what's configured, an
601
740
  * unknown id reports the valid options (never silently no-ops, never sends
602
- * a typo through to the model), a real id applies immediately -- "applies"
603
- * meaning `deps.system` will pick it up on the NEXT `resolveSystem()` call,
604
- * not this instant (see the field docs on TermSessionDeps.system). */
741
+ * a typo through to the model), a real id applies immediately.
742
+ *
743
+ * "applies" means `deps.system` will pick it up on the NEXT
744
+ * `resolveSystem()` call, not this instant (see the field docs on
745
+ * TermSessionDeps.system) -- `opts.apply` (the host's `setMode`/
746
+ * `setPersona`) is the whole story there.
747
+ *
748
+ * `/model` does NOT go through here -- see `runModelCommand`: the model is
749
+ * fixed for a session, so there is nothing for it to apply. */
605
750
  async switchIdentity(opts) {
606
751
  if (!opts.list || !opts.apply) {
607
752
  this.note("warn", `/${opts.kind} is not available in this session`);
@@ -623,6 +768,36 @@ var TermSession = class {
623
768
  opts.apply(found.id);
624
769
  this.note("info", `${opts.kind} set to ${found.label} -- takes effect on your next message`);
625
770
  }
771
+ /** `/model` -- READ-ONLY. No id lists what's available (see `listModels`'s
772
+ * field doc for why); an id refuses rather than switching, because the
773
+ * model is fixed for the life of a session -- `resolveContextWindow(model)`
774
+ * runs once at launch and sizes rung-2 compaction from it, so a downward
775
+ * switch would leave compaction believing it has a bigger window than the
776
+ * model actually has and the conversation would meet the context wall as a
777
+ * fatal 400 instead of compacting in time. Changing the model requires
778
+ * reprocessing the session, which is not implemented (docs/BACKLOG.md
779
+ * section 5) -- the remedy is a restart with `--model <id>`, not this
780
+ * command. Routed through `safeCall` for the listing, same guard every
781
+ * other identity command uses. */
782
+ async runModelCommand(id) {
783
+ const list = this.deps.listModels;
784
+ if (!list) {
785
+ this.note("warn", "/model is not available in this session");
786
+ return;
787
+ }
788
+ const listed = await this.safeCall("model", list);
789
+ if (!listed.ok) return;
790
+ const entries = listed.value;
791
+ if (!id) {
792
+ const names = entries.map((e) => e.id).join(", ");
793
+ this.note("info", names ? `models: ${names}` : `models: not known here -- the catalogue is fetched, so this is empty offline or before it answers. This session is on ${this.deps.model} either way.`);
794
+ return;
795
+ }
796
+ this.note(
797
+ "warn",
798
+ `the model is fixed for a session -- its context window, compaction sizing and pricing are all derived from it at start, and switching it mid-session cannot be done safely. Restart with --model ${id} to use it.`
799
+ );
800
+ }
626
801
  async armSkillCommand(id) {
627
802
  if (!this.deps.listSkills || !this.deps.armSkill) {
628
803
  this.note("warn", "/skill is not available in this session");
@@ -681,6 +856,306 @@ var TermSession = class {
681
856
  this.note("info", `running strategy "${resolved.value.name}"`);
682
857
  await this.queueOrRun(resolved.value.prompt);
683
858
  }
859
+ /** Like `safeCall`, but for `/mcp`'s own host calls -- a SEPARATE wrapper,
860
+ * not a shared message, because the failure shape is different in kind.
861
+ * `safeCall`'s "not available -- could not read the config workspace"
862
+ * framing was written for `/mode`/`/persona`: there, a throw really does
863
+ * mean the config workspace itself is broken. An MCP host call throwing is
864
+ * usually something else entirely -- `McpAdmin`'s write methods
865
+ * (packages/cli/src/mcpAdmin.ts) return `Promise<void>` with no result
866
+ * channel, so a REFUSAL (a duplicate name, a daemon saying no, a failed
867
+ * sign-in) surfaces as a thrown Error too, indistinguishable by shape from
868
+ * a real transport failure. Telling the user the command is "not
869
+ * available" when it ran and was refused is actively wrong -- it says "try
870
+ * nothing" when the honest answer is "try something else, here is why this
871
+ * attempt didn't work". So this wrapper names the ACTION and reports the
872
+ * underlying reason verbatim, and leaves `safeCall` untouched for the four
873
+ * callers whose failure really is "could not read the config workspace". */
874
+ async safeMcpCall(action, fn) {
875
+ try {
876
+ return { ok: true, value: await fn() };
877
+ } catch (e) {
878
+ this.note("warn", `/mcp ${action} failed: ${e instanceof Error ? e.message : String(e)}`);
879
+ return { ok: false };
880
+ }
881
+ }
882
+ /** `/mcp` and its subcommands -- what is connected, and sign in or add one.
883
+ * `listMcpServers` gates the whole command, the same way `deps.policy` gates
884
+ * `/policy`: a host that wires none of the five deps reports `/mcp` itself
885
+ * unavailable rather than half-working. Each mutating subcommand ALSO checks
886
+ * its own specific dep, so a host that wires read-only `listMcpServers` still
887
+ * gets a working `/mcp list` instead of every subcommand failing alike.
888
+ *
889
+ * `unknown` carries a subcommand `parseCommand` did not recognize -- see
890
+ * commands.ts's `mcp` case. It is rejected BY NAME rather than aliased to
891
+ * `list` (a typo must never produce ordinary-looking output with nothing
892
+ * to say it was not understood) and rather than reaching here as `null`
893
+ * from the parser (which would send it to the model as a chat message --
894
+ * worse still: a silent misfire that LOOKS like an answer). */
895
+ async runMcpCommand(sub, args, unknown) {
896
+ const listMcpServers = this.deps.listMcpServers;
897
+ if (!listMcpServers) {
898
+ this.note("warn", "/mcp is not available in this session");
899
+ return;
900
+ }
901
+ if (unknown) {
902
+ this.note("warn", `unknown /mcp subcommand "${unknown}" -- try: list, add, remove, login, refresh, forget`);
903
+ return;
904
+ }
905
+ switch (sub ?? "list") {
906
+ case "list": {
907
+ const listed = await this.safeMcpCall("list", listMcpServers);
908
+ if (!listed.ok) return;
909
+ this.note("info", formatMcpServers(listed.value, Date.now()));
910
+ return;
911
+ }
912
+ case "add": {
913
+ const [name, url] = args;
914
+ if (!name || !url) {
915
+ this.note("warn", "usage: /mcp add <name> <url>");
916
+ return;
917
+ }
918
+ const addMcpServer = this.deps.addMcpServer;
919
+ if (!addMcpServer) {
920
+ this.note("warn", "/mcp add is not available in this session");
921
+ return;
922
+ }
923
+ const added = await this.safeMcpCall("add", () => addMcpServer(name, url));
924
+ if (!added.ok) return;
925
+ this.note("info", `added mcp server "${name}"`);
926
+ return;
927
+ }
928
+ case "remove": {
929
+ const [name] = args;
930
+ if (!name) {
931
+ this.note("warn", "usage: /mcp remove <name>");
932
+ return;
933
+ }
934
+ const removeMcpServer = this.deps.removeMcpServer;
935
+ if (!removeMcpServer) {
936
+ this.note("warn", "/mcp remove is not available in this session");
937
+ return;
938
+ }
939
+ const removed = await this.safeMcpCall("remove", () => removeMcpServer(name));
940
+ if (!removed.ok) return;
941
+ this.note("info", `removed mcp server "${name}"`);
942
+ return;
943
+ }
944
+ case "login": {
945
+ const [name] = args;
946
+ if (!name) {
947
+ this.note("warn", "usage: /mcp login <name>");
948
+ return;
949
+ }
950
+ const loginMcpServer = this.deps.loginMcpServer;
951
+ if (!loginMcpServer) {
952
+ this.note("warn", "/mcp login is not available in this session");
953
+ return;
954
+ }
955
+ const loggedIn = await this.safeMcpCall("login", () => loginMcpServer(name, (url) => this.note("info", `sign in at: ${url}`)));
956
+ if (!loggedIn.ok) return;
957
+ this.note("info", `signed in to "${name}"`);
958
+ return;
959
+ }
960
+ case "refresh": {
961
+ const [name] = args;
962
+ const refreshMcpServer = this.deps.refreshMcpServer;
963
+ if (!refreshMcpServer) {
964
+ this.note("warn", "/mcp refresh is not available in this session");
965
+ return;
966
+ }
967
+ const refreshed = await this.safeMcpCall("refresh", () => refreshMcpServer(name));
968
+ if (!refreshed.ok) return;
969
+ this.note("info", name ? `refreshed "${name}"` : "refreshed all mcp servers");
970
+ return;
971
+ }
972
+ case "forget": {
973
+ const [name] = args;
974
+ if (!name) {
975
+ this.note("warn", "usage: /mcp forget <name>");
976
+ return;
977
+ }
978
+ const forgetMcpServer = this.deps.forgetMcpServer;
979
+ if (!forgetMcpServer) {
980
+ this.note("warn", "/mcp forget is not available in this session");
981
+ return;
982
+ }
983
+ const forgotten = await this.safeMcpCall("forget", () => forgetMcpServer(name));
984
+ if (!forgotten.ok) return;
985
+ this.note("info", `forgot the client registration for "${name}" -- the next /mcp login will register afresh`);
986
+ return;
987
+ }
988
+ }
989
+ }
990
+ /* ---- the Ink `/mcp` manager overlay ------------------------------------
991
+ *
992
+ * Reachable ONLY from `packages/term/src/ink/index.tsx` -- the readline
993
+ * face keeps sending bare `/mcp` through `submit()` -> `runMcpCommand`'s
994
+ * `'list'` case exactly as before (it has no key handler to drive anything
995
+ * richer, and no code path here changes for it). These methods are a
996
+ * SEPARATE entry point the Ink face uses instead of `submit()` once the
997
+ * overlay is open, so it can move a selection and re-render without a
998
+ * round trip through command parsing -- but every mutating action still
999
+ * calls the exact same host deps (`removeMcpServer`, `loginMcpServer`,
1000
+ * `listMcpServers`) that `runMcpCommand` above uses, so the overlay is
1001
+ * presentation over that one dispatch, never a second implementation of
1002
+ * it. `McpManager.servers` is a snapshot fetched here and re-fetched after
1003
+ * a mutation succeeds -- the overlay itself caches nothing (see
1004
+ * ink/McpOverlay.tsx). */
1005
+ /** `/mcp` with no argument, from the Ink face only -- see the module doc
1006
+ * above. Fetches the current servers and opens the manager. */
1007
+ async openMcpManager() {
1008
+ const listMcpServers = this.deps.listMcpServers;
1009
+ if (!listMcpServers) {
1010
+ this.note("warn", "/mcp is not available in this session");
1011
+ return;
1012
+ }
1013
+ const listed = await this.safeMcpCall("list", listMcpServers);
1014
+ if (!listed.ok) return;
1015
+ this.set({ modal: { kind: "mcp", servers: listed.value, selected: 0, login: null } });
1016
+ }
1017
+ /** esc, or any other reason the overlay should close -- a no-op unless the
1018
+ * manager is actually the modal open right now, so it can never steal a
1019
+ * permission or question modal's close. */
1020
+ closeMcpManager() {
1021
+ if (this.state.modal?.kind === "mcp") this.set({ modal: null });
1022
+ }
1023
+ /* ---- the Ink `/help` overlay -------------------------------------------
1024
+ *
1025
+ * Same split as `/mcp` above: `ink/index.tsx` intercepts a bare `/help`
1026
+ * before it reaches `submit()` and calls this instead; the readline face
1027
+ * has no overlay to open, so its `/help` keeps going through `submit()` ->
1028
+ * the `'help'` case in `runCommand` -> `note('info', formatHelpText())`,
1029
+ * unchanged -- both that text and this overlay render from the same
1030
+ * `TERM_COMMANDS` table (see commands.ts's `formatHelpText` and
1031
+ * ink/HelpOverlay.tsx), never a hand-written copy either face could fall
1032
+ * behind. This method needs no host dep and nothing to fetch -- opening it
1033
+ * is just setting the modal. */
1034
+ /** `/help` with no argument, from the Ink face only -- see the module doc
1035
+ * above. */
1036
+ openHelp() {
1037
+ this.set({ modal: { kind: "help" } });
1038
+ }
1039
+ /** esc, or any other reason the overlay should close -- a no-op unless the
1040
+ * help overlay is actually the modal open right now, so it can never steal
1041
+ * a permission, question, or MCP modal's close. */
1042
+ closeHelp() {
1043
+ if (this.state.modal?.kind === "help") this.set({ modal: null });
1044
+ }
1045
+ /* ---- the Ink `/context` overlay ----------------------------------------
1046
+ *
1047
+ * Same split as `/help`/`/mcp` above: `ink/index.tsx` intercepts a bare
1048
+ * `/context` before it reaches `submit()` and calls `openContext` instead;
1049
+ * the readline face's `/context` keeps going through `submit()` -> the
1050
+ * `'context'` case in `runCommand` -> `note('info', formatContext(...))`,
1051
+ * unchanged. Unlike the MCP manager, there is nothing to FETCH here --
1052
+ * every field `formatContext` needs already lives on `TuiState` (or is a
1053
+ * host-supplied getter), kept current by `handleEvent`'s `usage`/
1054
+ * `offload`/`checkpoint`/`tools-loaded` cases -- so opening the overlay is
1055
+ * just setting the modal, and it stays live: a turn that lands while it is
1056
+ * open updates the numbers the next time `App` re-renders, with no
1057
+ * re-fetch and no re-open. */
1058
+ /** The data `formatContext` (contextText.ts) needs, assembled from
1059
+ * `TuiState`'s own accumulators and the two host-supplied static facts
1060
+ * (`deps.tools.length`, `deps.contextWindow()`) -- called fresh by both
1061
+ * `runCommand`'s `'context'` case and the Ink overlay's render, never
1062
+ * cached, so neither face can show a stale window size after `/context`'s
1063
+ * OWN state changes mid-turn. */
1064
+ contextSnapshot() {
1065
+ return {
1066
+ sentTokens: this.state.sentTokens,
1067
+ contextWindow: this.deps.contextWindow?.() ?? null,
1068
+ offloaded: this.state.offloaded,
1069
+ checkpoints: this.state.checkpoints,
1070
+ toolCount: this.deps.tools.length,
1071
+ loadedToolNames: this.state.toolNames
1072
+ };
1073
+ }
1074
+ /** `/context` with no argument, from the Ink face only -- see the module
1075
+ * doc above. Needs no host dep and nothing to fetch -- opening it is just
1076
+ * setting the modal. */
1077
+ openContext() {
1078
+ this.set({ modal: { kind: "context" } });
1079
+ }
1080
+ /** esc, or any other reason the context overlay should close -- a no-op
1081
+ * unless the context overlay is actually the modal open right now, so it
1082
+ * can never steal a permission, question, help, or MCP modal's close. */
1083
+ closeContext() {
1084
+ if (this.state.modal?.kind === "context") this.set({ modal: null });
1085
+ }
1086
+ /** Up/down inside the overlay -- wraps, and does nothing with zero rows. */
1087
+ moveMcpSelection(delta) {
1088
+ const modal = this.state.modal;
1089
+ if (modal?.kind !== "mcp" || modal.servers.length === 0) return;
1090
+ const selected = (modal.selected + delta + modal.servers.length) % modal.servers.length;
1091
+ this.set({ modal: { ...modal, selected } });
1092
+ }
1093
+ /** `x` inside the overlay -- removes the selected row via the same
1094
+ * `removeMcpServer` dep `/mcp remove` uses, then refreshes the list. */
1095
+ async removeMcpSelection() {
1096
+ const modal = this.state.modal;
1097
+ if (modal?.kind !== "mcp") return;
1098
+ const server = modal.servers[modal.selected];
1099
+ if (!server) return;
1100
+ await this.runMcpCommand("remove", [server.name]);
1101
+ await this.refreshMcpManager();
1102
+ }
1103
+ /** `l` inside the overlay -- signs the selected row in via the same
1104
+ * `loginMcpServer` dep `/mcp login` uses. The consent URL genuinely
1105
+ * arrives before the sign-in itself completes (a daemon message, or a
1106
+ * synchronous callback locally), so `modal.login.url` is updated live as
1107
+ * it arrives rather than buffered until the whole call resolves. */
1108
+ async loginMcpSelection() {
1109
+ const modal = this.state.modal;
1110
+ if (modal?.kind !== "mcp" || modal.login) return;
1111
+ const server = modal.servers[modal.selected];
1112
+ if (!server) return;
1113
+ const loginMcpServer = this.deps.loginMcpServer;
1114
+ if (!loginMcpServer) {
1115
+ this.note("warn", "/mcp login is not available in this session");
1116
+ return;
1117
+ }
1118
+ this.set({ modal: { ...modal, login: { server: server.name, url: null } } });
1119
+ const result = await this.safeMcpCall("login", () => loginMcpServer(server.name, (url) => {
1120
+ const cur = this.state.modal;
1121
+ if (cur?.kind === "mcp" && cur.login?.server === server.name) {
1122
+ this.set({ modal: { ...cur, login: { server: server.name, url } } });
1123
+ }
1124
+ }));
1125
+ const after = this.state.modal;
1126
+ if (after?.kind !== "mcp") return;
1127
+ this.set({ modal: { ...after, login: null } });
1128
+ if (result.ok) {
1129
+ this.note("info", `signed in to "${server.name}"`);
1130
+ await this.refreshMcpManager();
1131
+ }
1132
+ }
1133
+ /** `r` inside the overlay -- refreshes the selected row via the same
1134
+ * `refreshMcpServer` dep `/mcp refresh` uses, then refreshes the list so
1135
+ * the updated tool count and age show without the user reopening the
1136
+ * overlay. Same shape as `removeMcpSelection` above. */
1137
+ async refreshMcpSelection() {
1138
+ const modal = this.state.modal;
1139
+ if (modal?.kind !== "mcp") return;
1140
+ const server = modal.servers[modal.selected];
1141
+ if (!server) return;
1142
+ await this.runMcpCommand("refresh", [server.name]);
1143
+ await this.refreshMcpManager();
1144
+ }
1145
+ /** Re-fetches `listMcpServers` and replaces the overlay's snapshot,
1146
+ * clamping the selection into range -- used after add/remove/login
1147
+ * succeed so the overlay never shows a server list that mutation just
1148
+ * made stale. */
1149
+ async refreshMcpManager() {
1150
+ const listMcpServers = this.deps.listMcpServers;
1151
+ if (!listMcpServers) return;
1152
+ const listed = await this.safeMcpCall("list", listMcpServers);
1153
+ if (!listed.ok) return;
1154
+ const cur = this.state.modal;
1155
+ if (cur?.kind !== "mcp") return;
1156
+ const selected = Math.min(cur.selected, Math.max(listed.value.length - 1, 0));
1157
+ this.set({ modal: { ...cur, servers: listed.value, selected } });
1158
+ }
684
1159
  /** Save after the turn, never during: a half-written turn is a lie on disk. */
685
1160
  async persist() {
686
1161
  const p = this.deps.persistence;
@@ -797,6 +1272,7 @@ ${line}` : line;
797
1272
  signal: ac.signal,
798
1273
  ...this.permission ? { permission: this.permission } : {},
799
1274
  ...this.deps.compression ? { compression: this.deps.compression } : {},
1275
+ ...this.deps.compaction ? { compaction: this.deps.compaction } : {},
800
1276
  onEvent: (e) => {
801
1277
  if (this.activeTurn === mine) this.handleEvent(e);
802
1278
  }
@@ -913,6 +1389,9 @@ ${line}` : line;
913
1389
  };
914
1390
 
915
1391
  export {
1392
+ mcpStatusWord,
1393
+ ageWord,
1394
+ formatContext,
916
1395
  parseMarkdown,
917
1396
  DELTA_FLUSH_MS,
918
1397
  TOOL_DELTA_CAP,