@plurnk/plurnk 0.77.1 → 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.
- package/.env.defaults +8 -0
- package/README.md +19 -12
- package/completions/_plurnk +2 -2
- package/completions/plurnk.bash +2 -2
- package/completions/plurnk.fish +3 -5
- package/conformance/agui-client.json +96 -62
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +7 -6
- package/dist/agents.js.map +1 -1
- package/dist/agui.d.ts +1 -0
- package/dist/agui.d.ts.map +1 -1
- package/dist/agui.js +4 -1
- package/dist/agui.js.map +1 -1
- package/dist/agui_cli.d.ts +4 -6
- package/dist/agui_cli.d.ts.map +1 -1
- package/dist/agui_cli.js +17 -11
- package/dist/agui_cli.js.map +1 -1
- package/dist/build-info.json +1 -1
- package/dist/commands.d.ts +223 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +120 -0
- package/dist/commands.js.map +1 -0
- package/dist/completion.d.ts.map +1 -1
- package/dist/completion.js +7 -4
- package/dist/completion.js.map +1 -1
- package/dist/dispatcher.d.ts +5 -13
- package/dist/dispatcher.d.ts.map +1 -1
- package/dist/dispatcher.js +84 -106
- package/dist/dispatcher.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +9 -8
- package/dist/mcp.js.map +1 -1
- package/dist/members.d.ts +6 -0
- package/dist/members.d.ts.map +1 -0
- package/dist/members.js +162 -0
- package/dist/members.js.map +1 -0
- package/dist/model-text.d.ts +6 -0
- package/dist/model-text.d.ts.map +1 -0
- package/dist/model-text.js +20 -0
- package/dist/model-text.js.map +1 -0
- package/dist/policy.d.ts +13 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +57 -0
- package/dist/policy.js.map +1 -0
- package/dist/proposal.d.ts +2 -9
- package/dist/proposal.d.ts.map +1 -1
- package/dist/proposal.js +10 -13
- package/dist/proposal.js.map +1 -1
- package/dist/render.d.ts +0 -5
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +13 -52
- package/dist/render.js.map +1 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +8 -7
- package/dist/skills.js.map +1 -1
- package/dist/status.d.ts +52 -3
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +143 -14
- package/dist/status.js.map +1 -1
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +3 -2
- package/dist/stream.js.map +1 -1
- package/dist/transport.d.ts +5 -16
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +6 -33
- package/dist/transport.js.map +1 -1
- package/dist/tui-guards.d.ts +8 -0
- package/dist/tui-guards.d.ts.map +1 -0
- package/dist/tui-guards.js +18 -0
- package/dist/tui-guards.js.map +1 -0
- package/dist/tui-surface.d.ts +23 -0
- package/dist/tui-surface.d.ts.map +1 -0
- package/dist/tui-surface.js +101 -0
- package/dist/tui-surface.js.map +1 -0
- package/dist/tui.d.ts +24 -16
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +297 -486
- package/dist/tui.js.map +1 -1
- package/man/plurnk.1 +43 -24
- package/package.json +9 -7
- package/dist/auth.d.ts +0 -13
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js +0 -46
- package/dist/auth.js.map +0 -1
- package/dist/paste.d.ts +0 -8
- package/dist/paste.d.ts.map +0 -1
- package/dist/paste.js +0 -103
- package/dist/paste.js.map +0 -1
package/dist/tui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// TUI mode — interactive
|
|
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
|
|
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
|
|
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,
|
|
22
|
-
import { renderProposalMenu, keyToResolution,
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
return { suggestions: [], prefix: line };
|
|
238
157
|
};
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
430
|
+
printAbove(" \x1b[2mcancelling… (ctrl-c again to quit)\x1b[0m");
|
|
529
431
|
void transport.rpc("loop.cancel", { reason }).catch((err) => {
|
|
530
|
-
|
|
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
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
648
|
-
lifecycle,
|
|
559
|
+
return renderStatusLine({
|
|
560
|
+
lifecycle: inFlight ? "running" : lifecycle,
|
|
649
561
|
model,
|
|
650
|
-
|
|
562
|
+
packetCount: null,
|
|
651
563
|
activity,
|
|
652
|
-
});
|
|
564
|
+
}, statusContext(), { idleGlyph: opts.yolo ? "🔥" : "" });
|
|
653
565
|
};
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
606
|
+
const rendered = renderReasoning(update.content);
|
|
607
|
+
liveReasoning.rendered = rendered;
|
|
739
608
|
if (update.phase === "content") {
|
|
740
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
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
|
|
833
|
-
if (pendingProposal !== null &&
|
|
834
|
-
onProposalKey(
|
|
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
|
-
|
|
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(
|
|
675
|
+
const verb = altShortcut(text);
|
|
847
676
|
if (verb !== null) {
|
|
848
677
|
dispatchShortcut(verb);
|
|
849
|
-
return;
|
|
678
|
+
return { consume: true };
|
|
850
679
|
}
|
|
851
|
-
|
|
852
|
-
};
|
|
853
|
-
|
|
854
|
-
//
|
|
855
|
-
|
|
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
|
-
|
|
868
|
-
+
|
|
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
|
-
|
|
702
|
+
printAbove(renderTuiFailure(cause));
|
|
881
703
|
}
|
|
882
704
|
showNextProposal();
|
|
883
|
-
reprompt();
|
|
884
705
|
};
|
|
885
|
-
// `e`/`/edit` → $EDITOR
|
|
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))
|
|
713
|
+
resolution = (await surface.handOff(() => keyToResolution("e", p)))
|
|
714
|
+
?? { decision: "cancel", outcome: "edit_failed" };
|
|
897
715
|
}
|
|
898
716
|
catch (cause) {
|
|
899
|
-
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
|
|
908
|
+
printAbove(` not readable: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1092
909
|
return;
|
|
1093
910
|
}
|
|
1094
|
-
|
|
911
|
+
surface.insertInput(content);
|
|
1095
912
|
},
|
|
1096
913
|
resolveProposal: async (action) => {
|
|
1097
914
|
if (pendingProposal === null) {
|
|
1098
|
-
|
|
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
|
|
1108
|
-
//
|
|
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(
|
|
929
|
+
edited = await surface.handOff(() => editInEditor(surface.editor.getExpandedText(), ".md"));
|
|
1118
930
|
}
|
|
1119
931
|
catch (cause) {
|
|
1120
|
-
|
|
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
|
-
|
|
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 →
|
|
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
|
-
|
|
944
|
+
requestClose();
|
|
1143
945
|
return;
|
|
1144
946
|
}
|
|
1145
947
|
}
|
|
1146
948
|
catch (cause) {
|
|
1147
|
-
|
|
949
|
+
printAbove(renderTuiFailure(cause));
|
|
1148
950
|
}
|
|
1149
951
|
reprompt();
|
|
1150
952
|
})();
|
|
1151
953
|
};
|
|
1152
|
-
reprompt();
|
|
1153
954
|
return new Promise((resolve) => {
|
|
1154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
+
close();
|
|
1197
1013
|
return;
|
|
1198
1014
|
}
|
|
1199
1015
|
}
|
|
1200
1016
|
catch (cause) {
|
|
1201
|
-
|
|
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
|
-
|
|
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
|
|
1253
|
-
//
|
|
1254
|
-
|
|
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 = {
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|