@plurnk/plurnk 0.77.0 → 0.78.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.
Files changed (88) hide show
  1. package/.env.defaults +8 -0
  2. package/README.md +19 -12
  3. package/completions/_plurnk +2 -2
  4. package/completions/plurnk.bash +2 -2
  5. package/completions/plurnk.fish +3 -5
  6. package/conformance/agui-client.json +96 -62
  7. package/dist/agents.d.ts.map +1 -1
  8. package/dist/agents.js +7 -6
  9. package/dist/agents.js.map +1 -1
  10. package/dist/agui.d.ts +1 -0
  11. package/dist/agui.d.ts.map +1 -1
  12. package/dist/agui.js +4 -1
  13. package/dist/agui.js.map +1 -1
  14. package/dist/agui_cli.d.ts +4 -6
  15. package/dist/agui_cli.d.ts.map +1 -1
  16. package/dist/agui_cli.js +17 -11
  17. package/dist/agui_cli.js.map +1 -1
  18. package/dist/build-info.json +1 -1
  19. package/dist/commands.d.ts +223 -0
  20. package/dist/commands.d.ts.map +1 -0
  21. package/dist/commands.js +120 -0
  22. package/dist/commands.js.map +1 -0
  23. package/dist/completion.d.ts.map +1 -1
  24. package/dist/completion.js +7 -4
  25. package/dist/completion.js.map +1 -1
  26. package/dist/dispatcher.d.ts +5 -13
  27. package/dist/dispatcher.d.ts.map +1 -1
  28. package/dist/dispatcher.js +84 -106
  29. package/dist/dispatcher.js.map +1 -1
  30. package/dist/mcp.d.ts.map +1 -1
  31. package/dist/mcp.js +9 -8
  32. package/dist/mcp.js.map +1 -1
  33. package/dist/members.d.ts +6 -0
  34. package/dist/members.d.ts.map +1 -0
  35. package/dist/members.js +162 -0
  36. package/dist/members.js.map +1 -0
  37. package/dist/model-text.d.ts +6 -0
  38. package/dist/model-text.d.ts.map +1 -0
  39. package/dist/model-text.js +20 -0
  40. package/dist/model-text.js.map +1 -0
  41. package/dist/policy.d.ts +13 -0
  42. package/dist/policy.d.ts.map +1 -0
  43. package/dist/policy.js +57 -0
  44. package/dist/policy.js.map +1 -0
  45. package/dist/proposal.d.ts +2 -9
  46. package/dist/proposal.d.ts.map +1 -1
  47. package/dist/proposal.js +10 -13
  48. package/dist/proposal.js.map +1 -1
  49. package/dist/render.d.ts +0 -5
  50. package/dist/render.d.ts.map +1 -1
  51. package/dist/render.js +13 -52
  52. package/dist/render.js.map +1 -1
  53. package/dist/skills.d.ts.map +1 -1
  54. package/dist/skills.js +8 -7
  55. package/dist/skills.js.map +1 -1
  56. package/dist/status.d.ts +52 -3
  57. package/dist/status.d.ts.map +1 -1
  58. package/dist/status.js +143 -14
  59. package/dist/status.js.map +1 -1
  60. package/dist/stream.d.ts.map +1 -1
  61. package/dist/stream.js +3 -2
  62. package/dist/stream.js.map +1 -1
  63. package/dist/transport.d.ts +5 -16
  64. package/dist/transport.d.ts.map +1 -1
  65. package/dist/transport.js +6 -33
  66. package/dist/transport.js.map +1 -1
  67. package/dist/tui-guards.d.ts +8 -0
  68. package/dist/tui-guards.d.ts.map +1 -0
  69. package/dist/tui-guards.js +18 -0
  70. package/dist/tui-guards.js.map +1 -0
  71. package/dist/tui-surface.d.ts +23 -0
  72. package/dist/tui-surface.d.ts.map +1 -0
  73. package/dist/tui-surface.js +101 -0
  74. package/dist/tui-surface.js.map +1 -0
  75. package/dist/tui.d.ts +24 -16
  76. package/dist/tui.d.ts.map +1 -1
  77. package/dist/tui.js +297 -486
  78. package/dist/tui.js.map +1 -1
  79. package/man/plurnk.1 +43 -24
  80. package/package.json +9 -7
  81. package/dist/auth.d.ts +0 -13
  82. package/dist/auth.d.ts.map +0 -1
  83. package/dist/auth.js +0 -46
  84. package/dist/auth.js.map +0 -1
  85. package/dist/paste.d.ts +0 -8
  86. package/dist/paste.d.ts.map +0 -1
  87. package/dist/paste.js +0 -103
  88. package/dist/paste.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,4 +1,4 @@
1
- // TUI mode — interactive REPL with glyph waterfall. Vanilla ANSI + readline.
1
+ // TUI mode — interactive Plurnk client on pi-tui's main-screen renderer.
2
2
  // Per TUI.md §3.
3
3
  //
4
4
  // Line language (converged with plurnk.nvim — one vocabulary, two surfaces):
@@ -7,50 +7,38 @@
7
7
  // ## LOOK0 (uri) off-run READ — inspect a uri's content for ME, not the model
8
8
  // ! cmd op.exec via the daemon
9
9
  // ... msg loop.inject — speak into the running model loop
10
- // ? text ask loop.run with flags.mode="ask"
10
+ // ? text deny EXEC for this loop and keep proposal review client-owned
11
11
  // : text act (the default)
12
12
  // text prompt
13
- // The readline prompt is `[🔥|⌛︎|progress%]: `: one three-cell slot, then input.
14
- import readline from "node:readline";
15
- import { PassThrough } from "node:stream";
16
13
  import { readFile } from "node:fs/promises";
17
14
  import { isAbsolute, resolve } from "node:path";
18
- import PasteFilter from "./paste.js";
15
+ import { matchesKey } from "@earendil-works/pi-tui";
16
+ import TuiSurface from "./tui-surface.js";
17
+ import TerminalGuards from "./tui-guards.js";
18
+ import ModelText from "./model-text.js";
19
19
  import { extractOpenPaths } from "./openpaths.js";
20
20
  import { pathPartial, completePath, dslOpPartial, completeOps, dslStatement } from "./completion.js";
21
- import { renderLogEntry, renderReasoningFrame, renderSummary, isPromptEntry, progressLabel, entryTarget, isEntryMaterialization } from "./render.js";
22
- import { renderProposalMenu, keyToResolution, isServerResolved, renderQuestionMenu, questionChoices, answerForQuestion, editInEditor } from "./proposal.js";
21
+ import { renderLogEntry, renderReasoning, renderSummary, isPromptEntry, entryTarget, isEntryMaterialization } from "./render.js";
22
+ import { renderProposalMenu, keyToResolution, renderQuestionMenu, questionChoices, answerForQuestion, editInEditor } from "./proposal.js";
23
23
  import { ProblemError, renderDiagnostic, report, clientSubcommandUnknownVerb, NO_MODEL_HINT } from "./diagnostics.js";
24
24
  import StreamTrace, { inlineable, renderInline } from "./stream.js";
25
25
  import { runModels, runWorkspaceList, runWorkspaceWorkers, runLogRead } from "./subcommands.js";
26
26
  import { Validator, } from "@plurnk/plurnk-contracts";
27
+ import { formatCapabilityProjection, parseCapabilityPolicy, promptPolicy } from "./policy.js";
27
28
  import { handleMcp } from "./mcp.js";
28
29
  import { handleSkills } from "./skills.js";
29
30
  import { handleAgents } from "./agents.js";
31
+ import { handleMembers } from "./members.js";
30
32
  import { formatWorkerReasoning, readWorkerReasoning, setWorkerReasoning, } from "./reasoning.js";
31
- import { derivationActivity, renderStatusLine } from "./status.js";
33
+ import { EMPTY_TALLY, derivationActivity, projectStatusGauge, renderStatusLine, tallyOutcome } from "./status.js";
34
+ import { COMMANDS, completeCommandSyntax, isCommandName, renderCommandHelp, } from "./commands.js";
32
35
  export const renderTuiFailure = (cause) => {
36
+ // A Problem may quote the model's own line; a thrown message may carry anything (plurnk#35).
33
37
  if (cause instanceof ProblemError) {
34
- return renderDiagnostic(cause.problem)
38
+ return renderDiagnostic(ModelText.plainFields(cause.problem))
35
39
  + (cause.problem.status === 501 ? NO_MODEL_HINT : "");
36
40
  }
37
- return ` \x1b[31merror: ${cause instanceof Error ? cause.message : String(cause)}\x1b[0m`;
38
- };
39
- export const renderInputPrompt = (yolo, activePercent, loopActive = false, status = null) => {
40
- const progressActive = activePercent !== null
41
- && Number.isFinite(activePercent)
42
- && activePercent < 100;
43
- if (status !== null) {
44
- return `${renderStatusLine({
45
- ...status,
46
- lifecycle: loopActive ? "running" : status.lifecycle,
47
- activity: status.activity ?? (progressActive ? { label: "", percent: Math.floor(activePercent) } : null),
48
- }, { idleGlyph: yolo ? "🔥" : "" })}\x1b[1m: \x1b[0m`;
49
- }
50
- const slot = progressActive ? progressLabel(activePercent)
51
- : loopActive ? "⌛︎ "
52
- : yolo ? "🔥 " : " ";
53
- return `${slot}\x1b[1m: \x1b[0m`;
41
+ return ` \x1b[31merror: ${ModelText.plain(cause instanceof Error ? cause.message : String(cause))}\x1b[0m`;
54
42
  };
55
43
  // One verb vocabulary across nvim's :AI/, the TUI, and (where they exist)
56
44
  // the argv subcommands. Convergence is policy: divergence needs a reason.
@@ -58,74 +46,20 @@ export const renderInputPrompt = (yolo, activePercent, loopActive = false, statu
58
46
  // lists them; /worker forks a new worker, /workers lists them. The old /new was
59
47
  // ambiguous (workspace or worker?) and is gone. /rename retargets the current
60
48
  // workspace's mutable handle (a worker's name is immutable — no /rename for workers).
61
- export const VERBS = [
62
- "help", "models", "workspaces", "workers", "log", "model", "child", "reasoning",
63
- "yolo", "workspace", "rename", "worker", "stop", "quit",
64
- "pick", "hide", "view", "drop", "members", "import", "script", "mcp", "editor",
65
- "accept", "reject", "cancel", "edit",
66
- ];
67
- export const TUI_HELP = [
68
- " /models [search] /workspaces /workers /log [n] inspect",
69
- " /model <selector> · /child <selector|inherit> parent and child models",
70
- " /reasoning [off|adaptive|effort] inspect or set reasoning",
71
- " /yolo toggle local auto-accept",
72
- " /workspace [name] new workspace",
73
- " /rename <name> rename this workspace (a mutable handle)",
74
- " /worker [name] create a new worker",
75
- " /pick <glob> track file(s)",
76
- " /hide <glob> hide file(s)",
77
- " /view <glob> track file(s) (read-only)",
78
- " /drop <glob> no longer pick file(s)",
79
- " /members the model's resolved file universe (+ rules)",
80
- " /import <path> dump a local file's content into the prompt",
81
- " /script <path> run a .plk file (its DSL → op.parse)",
82
- " /mcp list available workspace MCP servers",
83
- " add <alias> <target> [options.json] · enable/disable/remove <alias>",
84
- " oauth <alias> <callback-url>",
85
- " /agents list this Worker's outbound A2A agents",
86
- " discover <url> · add <alias> <url> [options.json] · enable/disable/remove <alias>",
87
- " /skills list this Worker's Agent Skills",
88
- " discover <query|source> · add <name> <source> [--global] · enable/disable/remove <name>",
89
- " /accept /reject /cancel /edit resolve a pending proposal (or keys a/e/r/c)",
90
- " /stop cancel the running loop",
91
- " /quit exit",
92
- " # PLAN0 / ## OP0 raw PLURNK (op.parse)",
93
- " ## LOOK0 (uri) off-run READ — inspect a uri for you, not the model",
94
- " Alt-p / Alt-n cycle ## LOOK0 through prior operations' targets",
95
- " Ctrl-J / Alt-Enter insert a ↵ newline (editable); Enter submits",
96
- " Esc cancel the running loop; idle: clear the line",
97
- " /editor · Alt-e compose the prompt line in $EDITOR",
98
- " Alt-m/s · Alt-R/L/Y/N/M · Alt-x quick verbs (nvim case): models workspaces runs",
99
- " log yolo workspace members stop · Alt-h help",
100
- ].join("\n") + "\n";
101
- // The non-submitting newline keys, by their raw byte sequence (post paste
102
- // filter): Ctrl-J is LF (`\n`, a distinct byte from Enter's CR — works on every
103
- // terminal); Alt-Enter is Meta+Return (ESC then CR/LF). Enter itself is CR
104
- // (`\r`) and is NOT matched here, so it still submits. Shift-Enter is absent by
105
- // design (it can't be distinguished without the kitty/modifyOtherKeys protocol).
106
- export const isNewlineKey = (forward) => forward === "\n" || forward === "\x1b\r" || forward === "\x1b\n";
107
- // A soft-enter inserts this single-width glyph into readline's ONE line, so the
108
- // whole multi-line buffer stays natively editable (backspace deletes the ↵ to
109
- // rejoin; arrows/history cross it; readline owns the wrapping). On submit it
110
- // expands back to a real newline. A pasted literal ↵ is preserved — expansion
111
- // runs on the typed line BEFORE paste markers expand, so paste content is
112
- // untouched.
113
- export const NL_MARK = "↵";
114
- export const expandNewlines = (line) => line.replaceAll(NL_MARK, "\n");
49
+ export const VERBS = COMMANDS.map(({ name }) => name);
50
+ export const TUI_HELP = renderCommandHelp();
115
51
  // Muscle-memory quick-keys, converged with plurnk.nvim's `<leader>a<letter>`
116
52
  // mnemonics — SAME CASE as nvim (lowercase m/s/x, capital R/L/Y/N/M), which
117
53
  // Alt-<letter> can carry (Alt-m = `ESC m`, Alt-M = `ESC M` — distinct bytes).
118
- // Delivered as Alt not Ctrl because Ctrl-<letter> collides with terminal/
119
- // readline control codes: Ctrl-m IS Enter, Ctrl-Shift-m is ALSO Enter (Shift
120
- // doesn't change a control code), Ctrl-s is XOFF, Ctrl-i is Tab, and readline
121
- // owns a/e/n/p/u/w/k/l. Alt-b/f/d are skipped (readline word-ops).
54
+ // Delivered as Alt not Ctrl because Ctrl-<letter> collides with terminal and
55
+ // editor control keys. Alt-b/f/d remain the editor's word operations.
122
56
  export const ALT_SHORTCUTS = Object.freeze({
123
57
  m: "/models", s: "/workspaces", R: "/workers", L: "/log",
124
58
  Y: "/yolo", N: "/workspace", M: "/members", x: "/stop", h: "/help", e: "/editor",
125
59
  });
126
60
  // An Alt-<letter> keypress (ESC then a single letter, no `[`/`O` → not an arrow
127
61
  // or function key) mapped to its verb, or null. Case-sensitive (mirrors nvim).
128
- // Reassembled by the paste filter whether it arrives in one chunk or split.
62
+ // pi-tui's terminal buffer reassembles split escape sequences.
129
63
  export const altShortcut = (forward) => {
130
64
  const m = forward.match(/^\x1b([a-zA-Z])$/);
131
65
  return m ? (ALT_SHORTCUTS[m[1]] ?? null) : null;
@@ -133,22 +67,10 @@ export const altShortcut = (forward) => {
133
67
  // Recognize the client-only LOOK surface so it can be routed to `op.look`.
134
68
  // The AG-UI observation action owns validation and the single LOOK→READ rewrite.
135
69
  export const lookStatement = (line) => line.startsWith("## LOOK") ? line : null;
136
- // §2.0 prompt prefixes (converged with nvim's :AI ? / :AI :): `? ` puts mode:"ask"
137
- // on the wire flags, `: ` forces act, both overriding a --flags base mode; `...`
138
- // (injection) strips without minting flags. Mode is a per-line habit, never --ask.
139
- export const lineMode = (trimmed, base) => {
140
- const prefix = trimmed[0];
141
- const flags = prefix === "?" || prefix === ":"
142
- ? { ...(base ?? {}), mode: prefix === "?" ? "ask" : "act" }
143
- : base;
144
- return { ...(flags !== undefined ? { flags } : {}), prompt: trimmed.replace(/^(\.\.\.|[?:]+)\s*/, "") };
145
- };
70
+ export const linePolicy = promptPolicy;
71
+ export const renderSubmittedInput = (text, yolo) => text.split("\n").map((line, index) => `${index === 0 ? (yolo ? "🔥 " : "› ") : " "}${line}`).join("\n");
146
72
  // Alt-p / Alt-n cycle the LOOK target through prior operations (prev/next op).
147
- // Alt-<letter> (`ESC<letter>`) survives the input pipeline intact — the paste
148
- // filter always holds a lone ESC and reassembles `ESC<letter>`, the same path the
149
- // Alt verb shortcuts use — whereas a Shift-Up CSI (`ESC[1;2A`) fragments at `ESC[1`
150
- // and leaks to readline as history-prev. Caught before altShortcut (which has no
151
- // lowercase p/n binding). null = not a cycle key.
73
+ // null = not a cycle key.
152
74
  export const cycleKey = (forward) => forward === "\x1bp" ? "up" : forward === "\x1bn" ? "down" : null;
153
75
  // Pure cursor math for the LOOK coordinate cycler over `count` seen coordinates
154
76
  // (oldest→newest). Up walks toward older (start at newest), down toward newer.
@@ -164,89 +86,108 @@ export const parseSlash = (line) => {
164
86
  const m = line.match(/^\/(\S*)\s*(.*)$/);
165
87
  return { verb: m?.[1] ?? "", rest: (m?.[2] ?? "").trim() };
166
88
  };
167
- // readline completer verbs after `/`, model aliases after `/model ` or `/child `.
168
- // Plain readline machinery only; no screen takeover, no terminal hell.
169
- // readline's async completer form (arity 2 async). Verb/alias completion
170
- // answers synchronously; path positions read the local fs (co-location law)
171
- // and answer once readdir resolves. A fragment holding a provider prefix
89
+ // Command syntax comes from the registry. Paths read the local filesystem;
90
+ // model and Functionality aliases are fetched only when the cursor reaches a
91
+ // position that consumes them. A fragment holding a provider prefix
172
92
  // (`/model openai/…`) completes lazily from one bounded, provider-scoped
173
93
  // daemon catalog page ([§cli-plurnk-models]); the client never preloads or
174
94
  // owns the Models.dev snapshot.
175
- export const makeCompleter = (getAliases, cwd, getReasoningPolicies = () => [], getProviderModels = async () => []) => (line, callback) => {
176
- const verbFrag = line.match(/^\/(\w*)$/);
177
- if (verbFrag) {
178
- callback(null, [VERBS.map((v) => `/${v}`).filter((v) => v.startsWith(line)), line]);
179
- return;
95
+ export const completeInput = async (line, options) => {
96
+ const command = completeCommandSyntax(line);
97
+ if (command?.kind === "syntax")
98
+ return { suggestions: command.suggestions, prefix: command.prefix };
99
+ if (command?.kind === "aliases") {
100
+ let aliases = [];
101
+ try {
102
+ aliases = await options.getFunctionalityAliases?.(command.family) ?? [];
103
+ }
104
+ catch { /* completion failure is an empty result; the editor remains intact */ }
105
+ return {
106
+ suggestions: aliases
107
+ .filter((alias) => alias.startsWith(command.prefix))
108
+ .map((value) => ({ value, description: `${command.family} alias` })),
109
+ prefix: command.prefix,
110
+ };
180
111
  }
181
112
  const aliasFrag = line.match(/^\/(model|child)\s+(\S*)$/);
182
113
  if (aliasFrag) {
183
114
  const fragment = aliasFrag[2];
184
115
  const provider = /^([A-Za-z0-9_-]+)\//.exec(fragment)?.[1];
185
116
  if (provider !== undefined) {
186
- getProviderModels(provider).then((selectors) => callback(null, [selectors.filter((selector) => selector.startsWith(fragment)), fragment]), () => callback(null, [[], fragment]));
187
- return;
117
+ let selectors = [];
118
+ try {
119
+ selectors = await options.getProviderModels?.(provider) ?? [];
120
+ }
121
+ catch { /* bounded remote completion is optional */ }
122
+ return {
123
+ suggestions: selectors.filter((selector) => selector.startsWith(fragment))
124
+ .map((value) => ({ value, description: "model route" })),
125
+ prefix: fragment,
126
+ };
188
127
  }
189
128
  const candidates = aliasFrag[1] === "child"
190
- ? ["inherit", ...getAliases().filter((alias) => alias !== "inherit")]
191
- : getAliases();
192
- callback(null, [candidates.filter((a) => a.startsWith(fragment)), fragment]);
193
- return;
129
+ ? ["inherit", ...options.getAliases().filter((alias) => alias !== "inherit")]
130
+ : options.getAliases();
131
+ return {
132
+ suggestions: candidates.filter((alias) => alias.startsWith(fragment))
133
+ .map((value) => ({ value, description: value === "inherit" ? "inherit parent route" : "model alias" })),
134
+ prefix: fragment,
135
+ };
194
136
  }
195
137
  const reasoningFrag = line.match(/^\/reasoning\s+(\S*)$/);
196
138
  if (reasoningFrag) {
197
- callback(null, [
198
- getReasoningPolicies().filter((policy) => policy.startsWith(reasoningFrag[1])),
199
- reasoningFrag[1],
200
- ]);
201
- return;
202
- }
203
- const mcpFrag = line.match(/^\/mcp\s+(\S*)$/);
204
- if (mcpFrag) {
205
- const fragment = mcpFrag[1];
206
- const commands = ["add", "enable", "disable", "remove", "oauth"]
207
- .filter((command) => command.startsWith(fragment));
208
- callback(null, [commands, fragment]);
209
- return;
210
- }
211
- const agentsFrag = line.match(/^\/agents\s+(\S*)$/);
212
- if (agentsFrag) {
213
- const fragment = agentsFrag[1];
214
- const commands = ["add", "discover", "enable", "disable", "remove"]
215
- .filter((command) => command.startsWith(fragment));
216
- callback(null, [commands, fragment]);
217
- return;
218
- }
219
- const skillsFrag = line.match(/^\/skills\s+(\S*)$/);
220
- if (skillsFrag) {
221
- const fragment = skillsFrag[1];
222
- const commands = ["add", "discover", "enable", "disable", "remove"]
223
- .filter((command) => command.startsWith(fragment));
224
- callback(null, [commands, fragment]);
225
- return;
139
+ return {
140
+ suggestions: (options.getReasoningPolicies?.() ?? [])
141
+ .filter((policy) => policy.startsWith(reasoningFrag[1]))
142
+ .map((value) => ({ value, description: "reasoning policy" })),
143
+ prefix: reasoningFrag[1],
144
+ };
226
145
  }
227
146
  const op = dslOpPartial(line);
228
147
  if (op !== null) {
229
- callback(null, completeOps(op));
230
- return;
148
+ const [values, prefix] = completeOps(op);
149
+ return { suggestions: values.map((value) => ({ value, description: "Plurnk operation" })), prefix };
231
150
  }
232
151
  const partial = pathPartial(line);
233
152
  if (partial !== null) {
234
- void completePath(partial, cwd).then((result) => callback(null, result));
235
- return;
153
+ const [values, prefix] = await completePath(partial, options.cwd);
154
+ return { suggestions: values.map((value) => ({ value, description: "local path" })), prefix };
236
155
  }
237
- callback(null, [[], line]);
156
+ return { suggestions: [], prefix: line };
238
157
  };
239
- // Seed readline history from the workspace's prior prompts (svc#238) so up/down
240
- // recalls them across restarts. One clean RPC — newest-first, exactly what
241
- // rl.history wants. Best-effort: a fresh workspace has none, failures are silent.
242
- export const seedPromptHistory = async (rpc, workspaceId, rl) => {
158
+ export const makeAutocompleteProvider = (options) => ({
159
+ triggerCharacters: ["/", "#", "@"],
160
+ getSuggestions: async (lines, cursorLine, cursorCol) => {
161
+ const beforeCursor = lines.slice(0, cursorLine).concat(lines[cursorLine]?.slice(0, cursorCol) ?? "").join("\n");
162
+ const { suggestions, prefix } = await completeInput(beforeCursor, options);
163
+ if (suggestions.length === 0)
164
+ return null;
165
+ return {
166
+ items: suggestions.map(({ value, description }) => ({ value, label: value, description })),
167
+ prefix,
168
+ };
169
+ },
170
+ applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
171
+ const current = lines[cursorLine] ?? "";
172
+ const before = current.slice(0, cursorCol);
173
+ const after = current.slice(cursorCol);
174
+ const replaceFrom = Math.max(0, before.length - prefix.length);
175
+ const completed = `${before.slice(0, replaceFrom)}${item.value}`;
176
+ const next = [...lines];
177
+ next[cursorLine] = completed + after;
178
+ return { lines: next, cursorLine, cursorCol: completed.length };
179
+ },
180
+ });
181
+ // Seed editor history from prior workspace prompts. The server returns newest
182
+ // first; the surface owns the insertion order required by its editor.
183
+ export const seedPromptHistory = async (rpc, workspaceId, history) => {
243
184
  try {
244
185
  // Bridge mode has no client-known workspace id → omit it (the connection's
245
186
  // attached workspace answers); WS passes the real id.
246
187
  const params = workspaceId > 0 ? { id: workspaceId, limit: 100 } : { limit: 100 };
247
188
  const { prompts } = await rpc.call("workspace.prompts", params);
248
189
  if (Array.isArray(prompts) && prompts.length > 0)
249
- rl.history = prompts;
190
+ history.addHistory(prompts);
250
191
  }
251
192
  catch { /* history is a convenience; never block the REPL */ }
252
193
  };
@@ -285,10 +226,17 @@ export const handleVerb = async (line, ctx) => {
285
226
  ctx.setSpawnModel(model.spawnModel);
286
227
  ctx.setReasoning(await readWorkerReasoning(rpc));
287
228
  };
229
+ if (verb.length === 0) {
230
+ write(renderCommandHelp());
231
+ return;
232
+ }
233
+ if (!isCommandName(verb)) {
234
+ report(clientSubcommandUnknownVerb(`/${verb}`, [...VERBS]));
235
+ return;
236
+ }
288
237
  switch (verb) {
289
- case "":
290
238
  case "help":
291
- write(TUI_HELP);
239
+ write(renderCommandHelp(rest));
292
240
  return;
293
241
  case "models":
294
242
  await runModels(rpc, {
@@ -343,6 +291,19 @@ export const handleVerb = async (line, ctx) => {
343
291
  write(`${renderTuiFailure(cause)}\n`);
344
292
  }
345
293
  return;
294
+ case "capabilities":
295
+ try {
296
+ const projection = rest.length === 0
297
+ ? await rpc.call("worker.capabilities.get")
298
+ : await rpc.call("worker.capabilities.set", {
299
+ policy: parseCapabilityPolicy("/capabilities", rest),
300
+ });
301
+ write(`${formatCapabilityProjection(projection).trimEnd().replace(/^/gm, " ")}\n`);
302
+ }
303
+ catch (cause) {
304
+ write(`${renderTuiFailure(cause)}\n`);
305
+ }
306
+ return;
346
307
  case "child":
347
308
  if (rest.length === 0) {
348
309
  write(` child: ${ctx.spawnModel === null ? "inherit" : resolvedModelLabel(ctx.spawnModel)}\n`);
@@ -395,70 +356,8 @@ export const handleVerb = async (line, ctx) => {
395
356
  write(` worker: ${forked.workerName} (new)\n`);
396
357
  return;
397
358
  }
398
- case "pick":
399
- case "hide":
400
- case "view": {
401
- // Membership overlay — service vocabulary, live via
402
- // workspace.constrain (workspace-scoped, re-resolved immediately).
403
- if (rest.length === 0) {
404
- write(` usage: /${verb} <glob>\n`);
405
- return;
406
- }
407
- await rpc.call("workspace.constrain", { effect: verb, glob: rest });
408
- write(` ${verb}: ${rest}\n`);
409
- return;
410
- }
411
- case "drop": {
412
- if (rest.length === 0) {
413
- write(" usage: /drop <glob>\n");
414
- return;
415
- }
416
- const { constraints } = await rpc.call("workspace.constraints");
417
- const matches = constraints.filter((c) => c.glob === rest);
418
- if (matches.length === 0) {
419
- write(` no constraint matching ${JSON.stringify(rest)}\n`);
420
- return;
421
- }
422
- for (const { effect, glob } of matches)
423
- await rpc.call("workspace.unconstrain", { effect, glob });
424
- write(` dropped ${matches.length} constraint${matches.length === 1 ? "" : "s"} (${rest})\n`);
425
- return;
426
- }
427
- case "members": {
428
- // The model's RESOLVED universe (svc#243) — daemon-resolved
429
- // (ls-files ∪ pick) − hide, never the client's rule globs. Showing
430
- // the rules here (the old behavior) misinforms: rules are deltas,
431
- // not the universe. The constraint list rides along as a footer —
432
- // it's what /drop targets, but it is NOT "what the model sees".
433
- const { members, hidden } = await rpc.call("workspace.members");
434
- const editable = members.filter((m) => m.effect === "member");
435
- const view = members.filter((m) => m.effect === "view");
436
- if (members.length === 0 && hidden.length === 0) {
437
- write(" the model's universe is empty — no Git members or /pick rules\n");
438
- }
439
- else {
440
- write(` the model's universe: ${members.length} file${members.length === 1 ? "" : "s"}`
441
- + ` — ${editable.length} editable, ${view.length} read-only`
442
- + `${hidden.length ? `, ${hidden.length} hidden` : ""}\n`);
443
- for (const m of view)
444
- write(` view ${m.path}\n`);
445
- for (const p of hidden)
446
- write(` hidden ${p}\n`);
447
- if (editable.length <= 40)
448
- for (const m of editable)
449
- write(` member ${m.path}\n`);
450
- else
451
- write(` member …${editable.length} editable files (git-tracked); listing suppressed\n`);
452
- }
453
- const { constraints } = await rpc.call("workspace.constraints");
454
- write(constraints.length === 0
455
- ? " rules: none (git-tracked files only)\n"
456
- : ` rules: ${constraints.map((c) => `${c.effect} ${c.glob}`).join(", ")}\n`);
457
- return;
458
- }
459
359
  case "import":
460
- // Dump a LOCAL file's content into the prompt (co-location law),
461
- // via the paste machinery. The rl/paste glue lives in ctx.importFile.
360
+ // Dump a LOCAL file's content into the multiline composer.
462
361
  if (rest.length === 0) {
463
362
  write(" usage: /import <path>\n");
464
363
  return;
@@ -492,6 +391,10 @@ export const handleVerb = async (line, ctx) => {
492
391
  await handleAgents(rest, rpc, write);
493
392
  return;
494
393
  }
394
+ case "members": {
395
+ await handleMembers(rest, rpc, write);
396
+ return;
397
+ }
495
398
  case "accept":
496
399
  case "reject":
497
400
  case "cancel":
@@ -507,16 +410,15 @@ export const handleVerb = async (line, ctx) => {
507
410
  return;
508
411
  case "quit":
509
412
  return "quit";
510
- default:
511
- report(clientSubcommandUnknownVerb(`/${verb}`, [...VERBS]));
512
- return;
513
413
  }
414
+ verb;
514
415
  };
515
416
  export const runTui = async (transport, workspace, opts) => {
516
417
  let current = workspace;
517
418
  // Loop state, hoisted so the line handler and SIGINT can share it.
518
419
  let inFlight = false;
519
420
  let cancelRequested = false;
421
+ let printAbove = (text) => { process.stdout.write(`${text}\n`); };
520
422
  // One cancel path for every interrupt gesture (Ctrl-C, Esc, /stop): the
521
423
  // run's active drain cancels via loop.cancel; the pending loop resolves
522
424
  // 499 and the REPL continues. A failed cancel SURFACES — a stop button
@@ -525,14 +427,18 @@ export const runTui = async (transport, workspace, opts) => {
525
427
  if (cancelRequested)
526
428
  return;
527
429
  cancelRequested = true;
528
- process.stdout.write("\r\x1b[2K \x1b[2mcancelling… (ctrl-c again to quit)\x1b[0m\n");
430
+ printAbove(" \x1b[2mcancelling… (ctrl-c again to quit)\x1b[0m");
529
431
  void transport.rpc("loop.cancel", { reason }).catch((err) => {
530
- process.stdout.write(`\r\x1b[2K \x1b[31mcancel failed: ${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
432
+ printAbove(` \x1b[31mcancel failed: ${err instanceof Error ? err.message : String(err)}\x1b[0m`);
531
433
  });
532
434
  };
533
435
  // Ephemeral progress projected into the prompt while background work is active.
534
436
  let lifecycle = "idle";
535
- let observedTurn = null;
437
+ let authoritativeStatus = null;
438
+ let tally = EMPTY_TALLY;
439
+ let runningSince = null;
440
+ let conversationWorkerId = null;
441
+ let conversationWorker = opts.workerName ?? null;
536
442
  let embedding = false;
537
443
  let embeddingPercent = null;
538
444
  let searchFetching = false;
@@ -544,14 +450,8 @@ export const runTui = async (transport, workspace, opts) => {
544
450
  const priorTargets = [];
545
451
  let lookCursor = null;
546
452
  let liveReasoning = null;
547
- // Print a line ABOVE the live readline prompt without eating the user's
548
- // in-progress input. The canonical "log while prompting" idiom: wipe the
549
- // prompt row, write the line, then re-render the prompt + current buffer on
550
- // the row below (rl.prompt(true) preserves what's typed). This is what lets a
551
- // loop stream traces AND keep a steer prompt the user can inject into — no
552
- // cursor/width math, readline owns the redraw. Reassigned to the real impl
553
- // once rl exists; until then (no loop can be running yet) it just appends.
554
- let printAbove = (text) => { process.stdout.write(`${text}\n`); };
453
+ let pendingProposal = null;
454
+ let pendingQuestion = null;
555
455
  // Streams, coalesced: one start line, one conclusion line, and tiny concluded
556
456
  // outputs inlined (the single bounded content fetch the TUI makes — SPEC §5.3).
557
457
  const streams = new StreamTrace();
@@ -620,12 +520,27 @@ export const runTui = async (transport, workspace, opts) => {
620
520
  reasoningPolicy: workerReasoning.policy,
621
521
  yolo: opts.yolo,
622
522
  });
623
- process.stdout.write(`\x1b[2m${header}\x1b[0m\n\n`);
523
+ const surface = new TuiSurface();
524
+ const releaseGuards = TerminalGuards.install(surface);
525
+ printAbove = (text) => surface.append(text);
526
+ surface.append(`\x1b[2m${header}\x1b[0m`);
527
+ surface.append("");
624
528
  if (reasoningFailure !== undefined)
625
- process.stdout.write(`${renderTuiFailure(reasoningFailure)}\n`);
529
+ printAbove(renderTuiFailure(reasoningFailure));
626
530
  // Client-owned lifecycle and model lead the input affordance; ephemeral
627
531
  // derivation, search, and branch work share its final activity position.
628
- const buildPrompt = () => {
532
+ const statusContext = () => ({
533
+ workspace: current.name,
534
+ worker: conversationWorker,
535
+ child: workerSpawnModel === null ? null : resolvedModelLabel(workerSpawnModel),
536
+ tally,
537
+ runningSince,
538
+ now: Date.now(),
539
+ });
540
+ const buildStatus = () => {
541
+ if (authoritativeStatus !== null) {
542
+ return renderStatusLine(authoritativeStatus, statusContext(), { idleGlyph: opts.yolo ? "🔥" : "" });
543
+ }
629
544
  const branchCompleted = Number(branchBatch?.completed);
630
545
  const branchTotal = Number(branchBatch?.total);
631
546
  const branchPercent = branchBatch !== null
@@ -634,9 +549,6 @@ export const runTui = async (transport, workspace, opts) => {
634
549
  && branchTotal > 0
635
550
  ? Math.floor((branchCompleted / branchTotal) * 100)
636
551
  : null;
637
- const activePercent = embedding ? embeddingPercent
638
- : searchFetching ? searchPercent
639
- : branchPercent;
640
552
  const activity = embedding ? { label: "indexing", percent: embeddingPercent }
641
553
  : searchFetching ? { label: "search", percent: searchPercent }
642
554
  : branchPercent !== null ? { label: "branches", percent: branchPercent }
@@ -644,28 +556,20 @@ export const runTui = async (transport, workspace, opts) => {
644
556
  const model = workerModel === null
645
557
  ? opts.modelSelector ?? activeAlias ?? null
646
558
  : resolvedModelLabel(workerModel);
647
- return renderInputPrompt(opts.yolo, activePercent, inFlight, {
648
- lifecycle,
559
+ return renderStatusLine({
560
+ lifecycle: inFlight ? "running" : lifecycle,
649
561
  model,
650
- turn: observedTurn,
562
+ packetCount: null,
651
563
  activity,
652
- });
564
+ }, statusContext(), { idleGlyph: opts.yolo ? "🔥" : "" });
653
565
  };
654
- // Bracketed-paste buffering (paste.ts): a multi-line paste must become ONE
655
- // prompt, not one loop.run per line. readline reads a PassThrough we feed
656
- // filtered stdin into; since the input is no longer the TTY directly, raw
657
- // mode and ?2004 are ours to manage (terminal:true keeps readline's
658
- // keypress decoding). Stream plumbing only — no cursor/width math.
659
- const paste = new PasteFilter();
660
- const input = new PassThrough();
661
- process.stdin.setRawMode?.(true);
662
- process.stdout.write("\x1b[?2004h");
663
- const rl = readline.createInterface({
664
- input,
665
- output: process.stdout,
666
- terminal: true,
667
- prompt: buildPrompt(),
668
- completer: makeCompleter(() => aliasCache, process.cwd(), () => workerReasoning.supportedPolicies, async (provider) => {
566
+ const reprompt = () => surface.setStatus(buildStatus());
567
+ const repromptPreserving = reprompt;
568
+ surface.setAutocompleteProvider(makeAutocompleteProvider({
569
+ getAliases: () => aliasCache,
570
+ cwd: process.cwd(),
571
+ getReasoningPolicies: () => workerReasoning.supportedPolicies,
572
+ getProviderModels: async (provider) => {
669
573
  // One bounded page per provider, cached for the session: lazy,
670
574
  // provider-scoped, never the whole catalog.
671
575
  const cached = providerModelCache.get(provider);
@@ -677,95 +581,40 @@ export const runTui = async (transport, workspace, opts) => {
677
581
  .filter((selector) => typeof selector === "string");
678
582
  providerModelCache.set(provider, selectors);
679
583
  return selectors;
680
- }),
681
- });
682
- const reprompt = () => {
683
- readline.cursorTo(process.stdout, 0);
684
- readline.clearLine(process.stdout, 0);
685
- rl.setPrompt(buildPrompt());
686
- rl.prompt();
687
- };
688
- // Repaint the prompt preserving a typed-in inject (rl.prompt(true)) — used
689
- // when active progress changes mid-typing. The
690
- // printAbove idiom; readline owns the redraw, no cursor/width math.
691
- const repromptPreserving = () => {
692
- readline.cursorTo(process.stdout, 0);
693
- readline.clearLine(process.stdout, 0);
694
- rl.setPrompt(buildPrompt());
695
- rl.prompt(true);
696
- };
697
- const clearPromptAndLiveReasoning = () => {
698
- readline.cursorTo(process.stdout, 0);
699
- readline.clearLine(process.stdout, 0);
700
- if (liveReasoning?.tailShown === true) {
701
- readline.moveCursor(process.stdout, 0, -1);
702
- readline.cursorTo(process.stdout, 0);
703
- readline.clearLine(process.stdout, 0);
704
- liveReasoning.tailShown = false;
705
- }
706
- };
707
- const redrawLiveReasoning = () => {
708
- if (liveReasoning !== null && liveReasoning.tail !== null) {
709
- process.stdout.write(`${liveReasoning.tail}\n`);
710
- liveReasoning.tailShown = true;
711
- }
712
- rl.prompt(true);
713
- };
714
- // Now that rl exists, printAbove can re-render the prompt after each line.
715
- // A live reasoning preview remains the final row above it, so concurrent
716
- // stream or worker activity cannot corrupt the preview's cursor ownership.
717
- printAbove = (text) => {
718
- clearPromptAndLiveReasoning();
719
- process.stdout.write(`${text}\n`);
720
- redrawLiveReasoning();
721
- };
584
+ },
585
+ getFunctionalityAliases: async (family) => {
586
+ const result = await transport.rpc(`worker.${family}.list`, {});
587
+ if (!Array.isArray(result.definitions))
588
+ throw new TypeError(`worker.${family}.list returned an invalid result.`);
589
+ return result.definitions
590
+ .map((definition) => definition !== null && typeof definition === "object"
591
+ ? definition.alias
592
+ : undefined)
593
+ .filter((alias) => typeof alias === "string");
594
+ },
595
+ }));
722
596
  const presentReasoning = (update) => {
723
597
  if (update.phase === "start") {
724
598
  if (liveReasoning !== null)
725
599
  throw new TypeError("A second reasoning message started before the first ended.");
726
- liveReasoning = {
727
- messageId: update.messageId,
728
- columns: process.stdout.columns ?? 80,
729
- committed: 0,
730
- tail: null,
731
- tailShown: false,
732
- };
600
+ liveReasoning = { messageId: update.messageId, rendered: "" };
733
601
  return;
734
602
  }
735
603
  if (liveReasoning === null || liveReasoning.messageId !== update.messageId) {
736
604
  throw new TypeError(`Reasoning ${update.phase} did not match the active message.`);
737
605
  }
738
- clearPromptAndLiveReasoning();
606
+ const rendered = renderReasoning(update.content);
607
+ liveReasoning.rendered = rendered;
739
608
  if (update.phase === "content") {
740
- const frame = renderReasoningFrame(update.content, liveReasoning.columns);
741
- if (frame.committed.length < liveReasoning.committed) {
742
- throw new TypeError("Growing reasoning replaced an already committed terminal row.");
743
- }
744
- const rows = frame.committed.slice(liveReasoning.committed);
745
- if (rows.length > 0)
746
- process.stdout.write(`${rows.join("\n")}\n`);
747
- liveReasoning.committed = frame.committed.length;
748
- liveReasoning.tail = frame.tail;
749
- redrawLiveReasoning();
609
+ surface.setLive(rendered);
750
610
  return;
751
611
  }
752
- const frame = renderReasoningFrame(update.content, liveReasoning.columns);
753
- const rows = frame.committed.slice(liveReasoning.committed);
754
- if (rows.length > 0)
755
- process.stdout.write(`${rows.join("\n")}\n`);
756
- if (frame.tail !== null)
757
- process.stdout.write(`${frame.tail}\n`);
612
+ surface.setLive(null);
613
+ if (rendered.length > 0)
614
+ printAbove(rendered);
758
615
  liveReasoning = null;
759
- rl.prompt(true);
760
- };
761
- // Replace the whole readline buffer with `text`, public-API only: Ctrl-U kills
762
- // to line start, Ctrl-K from there to the end (clearing whatever was typed),
763
- // then type the new text. No private fields, no cursor/width math.
764
- const setLine = (text) => {
765
- rl.write(null, { ctrl: true, name: "u" });
766
- rl.write(null, { ctrl: true, name: "k" });
767
- rl.write(text);
768
616
  };
617
+ const setLine = (text) => surface.setInput(text);
769
618
  // Alt-p/Alt-n: walk the REAL target URIs of prior operations and template a
770
619
  // `## LOOK0 (<that uri>)` line into the buffer — an editable starting point
771
620
  // (hand-edit before Enter). Nothing to cycle → leave the line be.
@@ -784,89 +633,62 @@ export const runTui = async (transport, workspace, opts) => {
784
633
  printAbove(content.length > 0 ? content : ` \x1b[2m(look ${r.status}: no content)\x1b[0m`);
785
634
  return r;
786
635
  };
787
- // Multi-line composition. The non-submitting newline keys insert a single ↵
788
- // marker into readline's ONE line; Enter submits, expanding ↵→\n. Keeping it
789
- // one readline line means backspace/arrows/history edit across newlines
790
- // natively — no second buffer, no cursor/width math, the same idiom as
791
- // paste. Two keys, one behavior (see isNewlineKey): Ctrl-J (LF) on every
792
- // terminal, Alt-Enter (Meta+CR) on most; Shift-Enter is unhandled by design
793
- // (needs the kitty/modifyOtherKeys protocols). Interposing on stdin
794
- // (paste.ts feeds readline a PassThrough) is where we catch the keys, so the
795
- // suppressed bytes never reach readline — which would otherwise submit on
796
- // `\n`. Each interactive keypress is its own chunk, so an exact match holds.
797
- // onStdin is the single keyboard dispatcher: pending-proposal key, paste,
798
- // the newline keys, the Alt-shortcuts, else readline. The proposal/shortcut
799
- // handlers are forward-declared (assigned once verbCtx exists).
800
- let pendingProposal = null;
636
+ // pi-tui owns multiline input, paste normalization, modern keyboard
637
+ // negotiation, history navigation, wrapping, cursor placement, and IME.
638
+ // Plurnk's listener consumes only product-level gestures before the editor.
801
639
  let onProposalKey = () => { };
802
- // A pending request-user-input question ({§question-tool}): answered by TYPING
803
- // (a number picks an enum choice; anything else is free response), NOT the
804
- // a/e/r/c keypress path — free response needs text. The line handler
805
- // intercepts while this is set.
806
- let pendingQuestion = null;
807
640
  let dispatchShortcut = () => { };
808
- const onStdin = (chunk) => {
809
- const text = chunk.toString("utf8");
810
- // Esc interrupts (plurnk#25) — the agent-CLI convention: bare Esc with
811
- // a loop in flight cancels it (same path as the first Ctrl-C); idle it
812
- // clears the composed line. Read BEFORE the paste filter, which would
813
- // otherwise hold a lone ESC as a possible split \x1b[200~ prefix. An
814
- // exact lone-ESC chunk while no paste is open is the KEY — the same
815
- // whole-chunk rule the Alt-shortcuts rely on.
816
- if (text === "\x1b" && paste.idle()) {
641
+ let requestClose = () => { };
642
+ const removeInputListener = surface.addInputListener((text) => {
643
+ if (matchesKey(text, "escape")) {
817
644
  if (inFlight) {
818
645
  requestCancel("user_escape");
819
- return;
820
- }
821
- if (rl.line.length > 0) {
822
- rl.write(null, { ctrl: true, name: "e" });
823
- rl.write(null, { ctrl: true, name: "u" });
646
+ return { consume: true };
824
647
  }
825
- return;
648
+ if (surface.editor.getText().length > 0)
649
+ surface.setInput("");
650
+ return { consume: true };
651
+ }
652
+ if (matchesKey(text, "ctrl+c")) {
653
+ if (inFlight && !cancelRequested)
654
+ requestCancel("user_sigint");
655
+ else
656
+ requestClose();
657
+ return { consume: true };
658
+ }
659
+ if (matchesKey(text, "ctrl+d") && surface.editor.getText().length === 0) {
660
+ requestClose();
661
+ return { consume: true };
826
662
  }
827
- const forward = paste.feed(text);
828
- if (forward.length === 0)
829
- return;
830
663
  // A pending proposal + an EMPTY prompt line: a single review key
831
664
  // (a/e/r/c) resolves it. Anything else — including typing `/accept` —
832
- // falls through to readline, so the no-modifier verb fallback works.
833
- if (pendingProposal !== null && rl.line.length === 0 && /^[aerc]$/i.test(forward)) {
834
- onProposalKey(forward);
835
- return;
665
+ // falls through to the editor, so the typed verb fallback works.
666
+ if (pendingProposal !== null && surface.editor.getText().length === 0 && /^[aerc]$/i.test(text)) {
667
+ onProposalKey(text);
668
+ return { consume: true };
836
669
  }
837
- if (isNewlineKey(forward)) {
838
- rl.write(NL_MARK);
839
- return;
840
- }
841
- const dir = cycleKey(forward);
670
+ const dir = cycleKey(text);
842
671
  if (dir !== null) {
843
672
  cycleLook(dir);
844
- return;
673
+ return { consume: true };
845
674
  }
846
- const verb = altShortcut(forward);
675
+ const verb = altShortcut(text);
847
676
  if (verb !== null) {
848
677
  dispatchShortcut(verb);
849
- return;
678
+ return { consume: true };
850
679
  }
851
- input.write(forward);
852
- };
853
- process.stdin.on("data", onStdin);
854
- // Cross-restart up/down history from the daemon (svc#238) non-blocking.
855
- void seedPromptHistory({ call: (m, p) => transport.rpc(m, p) }, current.id, rl);
856
- // Proposal lifecycle — NON-BLOCKING. Server-resolved (flags.auto/noProposals)
857
- // settle in-process → skip. --yolo auto-accepts. A real
858
- // proposal renders its menu and parks as `pendingProposal` (queue beyond),
859
- // WITHOUT pausing readline or grabbing stdin: resolve via a single key
860
- // (onStdin, above) OR the typed `/accept`/`/reject`/`/cancel`/`/edit` verbs.
861
- // Only the `$EDITOR` path hands the terminal off — and only for its spawn.
680
+ return undefined;
681
+ });
682
+ void seedPromptHistory({ call: (m, p) => transport.rpc(m, p) }, current.id, surface);
683
+ // Proposal lifecycle stays non-blocking. The editor remains available for
684
+ // a/e/r/c or the equivalent typed verbs; only $EDITOR takes terminal custody.
862
685
  const proposalQueue = [];
863
686
  const showNextProposal = () => {
864
687
  if (pendingProposal !== null || proposalQueue.length === 0)
865
688
  return;
866
689
  pendingProposal = proposalQueue.shift();
867
- process.stdout.write(`\r\x1b[2K${renderProposalMenu(pendingProposal)}\n`
868
- + `\x1b[2m resolve: a/e/r/c or /accept /reject /cancel /edit\x1b[0m\n`);
869
- reprompt();
690
+ printAbove(`${renderProposalMenu(pendingProposal)}\n`
691
+ + "\x1b[2m resolve: a/e/r/c or /accept /reject /cancel /edit\x1b[0m");
870
692
  };
871
693
  const resolvePending = async (resolution) => {
872
694
  const p = pendingProposal;
@@ -877,36 +699,27 @@ export const runTui = async (transport, workspace, opts) => {
877
699
  await transport.resolve({ logEntryId: p.logEntryId, ...resolution });
878
700
  }
879
701
  catch (cause) {
880
- process.stdout.write(`${renderTuiFailure(cause)}\n`);
702
+ printAbove(renderTuiFailure(cause));
881
703
  }
882
704
  showNextProposal();
883
- reprompt();
884
705
  };
885
- // `e`/`/edit` → $EDITOR. It needs the terminal: detach onStdin + raw-off for
886
- // the spawn, restore after. The ONE place the non-blocking design still hands
887
- // off — bounded to the editor's lifetime.
706
+ // `e`/`/edit` → $EDITOR through pi-tui's bounded terminal handoff.
888
707
  const editAndResolve = async () => {
889
708
  const p = pendingProposal;
890
709
  if (p === null)
891
710
  return;
892
- process.stdin.off("data", onStdin);
893
- process.stdin.setRawMode?.(false);
894
711
  let resolution;
895
712
  try {
896
- resolution = (await keyToResolution("e", p)) ?? { decision: "cancel", outcome: "edit_failed" };
713
+ resolution = (await surface.handOff(() => keyToResolution("e", p)))
714
+ ?? { decision: "cancel", outcome: "edit_failed" };
897
715
  }
898
716
  catch (cause) {
899
- process.stdout.write(`${renderTuiFailure(cause)}\n`);
717
+ printAbove(renderTuiFailure(cause));
900
718
  resolution = { decision: "cancel", outcome: "edit_error" };
901
719
  }
902
- finally {
903
- process.stdin.setRawMode?.(true);
904
- process.stdin.resume();
905
- process.stdin.on("data", onStdin);
906
- }
907
720
  await resolvePending(resolution);
908
721
  };
909
- // Single review key (from onStdin): a/r/c resolve directly, e edits.
722
+ // Single review key: a/r/c resolve directly, e edits.
910
723
  onProposalKey = (key) => {
911
724
  if (key.toLowerCase() === "e") {
912
725
  void editAndResolve();
@@ -981,13 +794,15 @@ export const runTui = async (transport, workspace, opts) => {
981
794
  const target = entryTarget(entry);
982
795
  if (target !== null)
983
796
  priorTargets.push(target);
984
- if (entry.origin === "model" && Number.isSafeInteger(entry.turn_seq))
985
- observedTurn = entry.turn_seq;
986
- printAbove(renderLogEntry(entry, process.stdout.columns ?? 80));
797
+ printAbove(renderLogEntry(entry, surface.columns || 80));
987
798
  },
988
799
  onNotice: handleNotice,
989
800
  onProblem: (problem) => printAbove(renderDiagnostic(problem)),
990
801
  onBranchBatch: handleBranchBatch,
802
+ onStatus: (gauge) => {
803
+ authoritativeStatus = projectStatusGauge(gauge.plurnk.status);
804
+ repromptPreserving();
805
+ },
991
806
  onStream: (payload) => {
992
807
  // One channel for the lifecycle: concluded carries its exact result, a start
993
808
  // event carries state. One start line, one conclusion line, tiny outputs inlined.
@@ -1010,8 +825,6 @@ export const runTui = async (transport, workspace, opts) => {
1010
825
  }
1011
826
  },
1012
827
  onProposal: (p) => {
1013
- if (isServerResolved(p))
1014
- return;
1015
828
  if (opts.yolo) {
1016
829
  void transport.resolve({ logEntryId: p.logEntryId, decision: "accept", outcome: "client_yolo" })
1017
830
  .catch((cause) => printAbove(` \x1b[31mauto-accept failed: ${cause instanceof Error ? cause.message : String(cause)}\x1b[0m`));
@@ -1061,6 +874,10 @@ export const runTui = async (transport, workspace, opts) => {
1061
874
  }
1062
875
  };
1063
876
  derivationPoll = setInterval(() => { void pollDerivation(); }, 1_000);
877
+ // The running loop's elapsed time ticks in the status row.
878
+ const statusTick = setInterval(() => { if (inFlight)
879
+ reprompt(); }, 1_000);
880
+ statusTick.unref();
1064
881
  void pollDerivation();
1065
882
  // Verbs + read-only subcommands call rpc.call(...) only; route that through the
1066
883
  // live transport (WS, or the bridge's management plane over /plurnk/rpc). A
@@ -1080,7 +897,7 @@ export const runTui = async (transport, workspace, opts) => {
1080
897
  getWorkspace: () => current,
1081
898
  setWorkspace: (s) => { current = s; },
1082
899
  switchWorkspace: (name) => transport.useSession(name, { projectRoot: opts.projectRoot, client: opts.client }),
1083
- write: (text) => { process.stdout.write(text); },
900
+ write: (text) => { printAbove(text); },
1084
901
  importFile: async (rest) => {
1085
902
  const abs = isAbsolute(rest) ? rest : resolve(process.cwd(), rest);
1086
903
  let content;
@@ -1088,14 +905,14 @@ export const runTui = async (transport, workspace, opts) => {
1088
905
  content = await readFile(abs, "utf8");
1089
906
  }
1090
907
  catch (cause) {
1091
- process.stdout.write(` not readable: ${cause instanceof Error ? cause.message : String(cause)}\n`);
908
+ printAbove(` not readable: ${cause instanceof Error ? cause.message : String(cause)}`);
1092
909
  return;
1093
910
  }
1094
- rl.write(paste.stash(content));
911
+ surface.insertInput(content);
1095
912
  },
1096
913
  resolveProposal: async (action) => {
1097
914
  if (pendingProposal === null) {
1098
- process.stdout.write(" (no pending proposal)\n");
915
+ printAbove(" (no pending proposal)");
1099
916
  return;
1100
917
  }
1101
918
  if (action === "edit") {
@@ -1104,54 +921,55 @@ export const runTui = async (transport, workspace, opts) => {
1104
921
  }
1105
922
  await resolvePending({ decision: action });
1106
923
  },
1107
- // /editor · Alt-e (plurnk#26): seed $EDITOR with the composed line
1108
- // (markers expanded), place the result BACK on the line — never
1109
- // auto-submit (bash Ctrl-X Ctrl-E executes on exit; a prompt line
1110
- // spends model tokens, so Enter stays the only submit). Same bounded
1111
- // terminal handoff as proposal edit. Empty buffer ⇒ line unchanged.
924
+ // /editor · Alt-e: place the edited multiline value back in the composer;
925
+ // Enter remains the only submit gesture.
1112
926
  composeInEditor: async () => {
1113
- process.stdin.off("data", onStdin);
1114
- process.stdin.setRawMode?.(false);
1115
927
  let edited = null;
1116
928
  try {
1117
- edited = await editInEditor(paste.expand(expandNewlines(rl.line)), ".md");
929
+ edited = await surface.handOff(() => editInEditor(surface.editor.getExpandedText(), ".md"));
1118
930
  }
1119
931
  catch (cause) {
1120
- process.stdout.write(`${renderTuiFailure(cause)}\n`);
1121
- }
1122
- finally {
1123
- process.stdin.setRawMode?.(true);
1124
- process.stdin.resume();
1125
- process.stdin.on("data", onStdin);
932
+ printAbove(renderTuiFailure(cause));
1126
933
  }
1127
934
  if (edited === null)
1128
935
  return;
1129
- rl.write(null, { ctrl: true, name: "e" });
1130
- rl.write(null, { ctrl: true, name: "u" });
1131
- rl.write(paste.stash(edited.replace(/\n$/, "")));
936
+ surface.setInput(edited.replace(/\n$/, ""));
1132
937
  },
1133
938
  };
1134
- // Alt-<letter> shortcut → run the verb. Wipe the prompt line, dispatch
1135
- // through the same handleVerb the typed `/verb` uses (the partial prompt in
1136
- // readline's buffer survives), then reprompt. Print-above pattern; no math.
939
+ // Alt-<letter> shortcut → the same verb contract as typed `/verb`.
1137
940
  dispatchShortcut = (verb) => {
1138
- process.stdout.write("\r\x1b[2K");
1139
941
  void (async () => {
1140
942
  try {
1141
943
  if (await handleVerb(verb, verbCtx) === "quit") {
1142
- rl.close();
944
+ requestClose();
1143
945
  return;
1144
946
  }
1145
947
  }
1146
948
  catch (cause) {
1147
- process.stdout.write(`${renderTuiFailure(cause)}\n`);
949
+ printAbove(renderTuiFailure(cause));
1148
950
  }
1149
951
  reprompt();
1150
952
  })();
1151
953
  };
1152
- reprompt();
1153
954
  return new Promise((resolve) => {
1154
- rl.on("line", async (line) => {
955
+ let closed = false;
956
+ const close = () => {
957
+ if (closed)
958
+ return;
959
+ closed = true;
960
+ shuttingDown = true;
961
+ if (derivationPoll !== null)
962
+ clearInterval(derivationPoll);
963
+ transport.shutdown();
964
+ removeInputListener();
965
+ surface.stop();
966
+ process.stdout.write(` \x1b[2mresume this workspace: plurnk --workspace ${current.name}\x1b[0m\n`);
967
+ resolve();
968
+ };
969
+ requestClose = close;
970
+ const submit = async (line) => {
971
+ if (line.trim().length > 0)
972
+ printAbove(renderSubmittedInput(line, opts.yolo));
1155
973
  // A pending request-user-input question consumes the typed line as its
1156
974
  // answer, BEFORE any verb/prompt/inject handling: a number picks an
1157
975
  // enum choice, anything else is free response (or raw JSON for a
@@ -1170,9 +988,7 @@ export const runTui = async (transport, workspace, opts) => {
1170
988
  reprompt();
1171
989
  return;
1172
990
  }
1173
- // Expand typed ↵ markers (Ctrl-J / Alt-Enter newlines) FIRST, then
1174
- // paste markers — so a pasted literal ↵ stays literal.
1175
- const trimmed = paste.expand(expandNewlines(line)).trim();
991
+ const trimmed = line.trim();
1176
992
  if (trimmed.length === 0) {
1177
993
  reprompt();
1178
994
  return;
@@ -1193,12 +1009,12 @@ export const runTui = async (transport, workspace, opts) => {
1193
1009
  }
1194
1010
  try {
1195
1011
  if (await handleVerb(trimmed, verbCtx) === "quit") {
1196
- rl.close();
1012
+ close();
1197
1013
  return;
1198
1014
  }
1199
1015
  }
1200
1016
  catch (cause) {
1201
- process.stdout.write(`${renderTuiFailure(cause)}\n`);
1017
+ printAbove(renderTuiFailure(cause));
1202
1018
  }
1203
1019
  reprompt();
1204
1020
  return;
@@ -1221,11 +1037,12 @@ export const runTui = async (transport, workspace, opts) => {
1221
1037
  }
1222
1038
  inFlight = true;
1223
1039
  lifecycle = "running";
1224
- observedTurn = null;
1040
+ authoritativeStatus = null;
1225
1041
  // Keep a live steer prompt for the duration of the loop so traces can
1226
1042
  // print above an editable injection row.
1227
1043
  reprompt();
1228
1044
  const start = Date.now();
1045
+ runningSince = start;
1229
1046
  let turnCount = 0;
1230
1047
  let terminalResult = { status: 0 };
1231
1048
  let hitMaxTurns = false;
@@ -1249,14 +1066,13 @@ export const runTui = async (transport, workspace, opts) => {
1249
1066
  terminalResult = await transport.rpc("op.exec", { command });
1250
1067
  }
1251
1068
  else {
1252
- // Prompt. `? ` = ask (read-only loop, flags.mode="ask");
1253
- // `: ` = act. Per-line prefix overrides --flags mode.
1254
- const { flags: lineFlags, prompt: promptText } = lineMode(trimmed, opts.loopFlags);
1069
+ // Prompt prefixes are client policy projections. `?` narrows
1070
+ // the ordinary loop by denying EXEC and retaining review;
1071
+ // `:` is the unmodified base policy.
1072
+ const { policy, prompt: promptText } = linePolicy(trimmed, opts.loopPolicy);
1255
1073
  // {§worker-model-selection} — no model selector rides the loop: the
1256
1074
  // worker owns the model; /model and /child persisted it server-side.
1257
- const loopParams = { prompt: promptText, requestUserInput: opts.requestUserInput !== false };
1258
- if (lineFlags !== undefined && Object.keys(lineFlags).length > 0)
1259
- loopParams.flags = lineFlags;
1075
+ const loopParams = { policy };
1260
1076
  if (opts.maxTurns !== undefined)
1261
1077
  loopParams.maxTurns = opts.maxTurns;
1262
1078
  const openPaths = extractOpenPaths(promptText); // @file refs → daemon turn-0 READs (#260)
@@ -1270,6 +1086,14 @@ export const runTui = async (transport, workspace, opts) => {
1270
1086
  hitMaxTurns = t.hitMaxTurns;
1271
1087
  turnCount = t.turnIds?.length ?? 0;
1272
1088
  usage = t.usage;
1089
+ if (t.workerId !== undefined && t.workerId !== conversationWorkerId) {
1090
+ conversationWorkerId = t.workerId;
1091
+ const { workers } = await transport.rpc("workspace.workers");
1092
+ const hit = workers.find((worker) => worker.id === conversationWorkerId);
1093
+ if (hit === undefined)
1094
+ throw new Error(`worker ${conversationWorkerId} concluded a loop but workspace.workers does not list it`);
1095
+ conversationWorker = hit.name;
1096
+ }
1273
1097
  }
1274
1098
  lifecycle = terminalResult.status === 202 ? "parked"
1275
1099
  : terminalResult.status === 499 ? "cancelled"
@@ -1283,38 +1107,25 @@ export const runTui = async (transport, workspace, opts) => {
1283
1107
  printAbove(renderTuiFailure(cause));
1284
1108
  }
1285
1109
  finally {
1110
+ tally = tallyOutcome(tally, { turns: turnCount, wallMs: Date.now() - start, usage });
1111
+ runningSince = null;
1286
1112
  inFlight = false;
1287
1113
  cancelRequested = false;
1288
1114
  pendingQuestion = null; // loop ended (incl. cancel) → drop any unanswered question
1289
1115
  reprompt();
1290
1116
  }
1291
- });
1292
- rl.on("close", () => {
1293
- shuttingDown = true; // intentional teardown — the rpc.close that follows isn't a "lost connection"
1294
- if (derivationPoll !== null)
1295
- clearInterval(derivationPoll);
1296
- transport.shutdown(); // suppress the transport's connection-lost reject on an intentional quit
1297
- process.stdout.write("\x1b[?2004l");
1298
- process.stdin.off("data", onStdin);
1299
- process.stdin.setRawMode?.(false);
1300
- process.stdin.pause();
1301
- // Steal pi's nicety: hand back the one-liner to pick this workspace up.
1302
- process.stdout.write(`\n \x1b[2mresume this workspace: plurnk --workspace ${current.name}\x1b[0m\n`);
1303
- resolve();
1304
- });
1305
- rl.on("SIGINT", () => {
1306
- // First Ctrl-C with a dispatch in flight: cancel the run's active
1307
- // drain via the loop.cancel action; the pending loop resolves with
1308
- // finalStatus 499 and the REPL continues. Second Ctrl-C (or idle
1309
- // Ctrl-C) exits — escape hatch for dispatches a drain-cancel can't
1310
- // unblock (op.parse). A failed cancel SURFACES — a stop button that
1311
- // silently does nothing is the worst kind of broken.
1312
- if (inFlight && !cancelRequested) {
1313
- requestCancel("user_sigint");
1314
- return;
1315
- }
1316
- rl.close();
1317
- });
1117
+ };
1118
+ surface.editor.onSubmit = (line) => {
1119
+ if (line.trim().length > 0)
1120
+ surface.editor.addToHistory(line);
1121
+ void submit(line);
1122
+ };
1123
+ reprompt();
1124
+ surface.start();
1125
+ }).finally(() => {
1126
+ clearInterval(statusTick);
1127
+ releaseGuards();
1128
+ surface.stop();
1318
1129
  });
1319
1130
  };
1320
1131
  //# sourceMappingURL=tui.js.map