@giovannijecha/jecode 0.1.6-rc.0 → 0.1.6
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/README.md +6 -4
- package/dist/commands.js +0 -1
- package/dist/controller.js +6 -1
- package/dist/credential-commands.js +0 -4
- package/dist/ollama-settings-command.js +0 -1
- package/dist/provider-commands.js +0 -2
- package/dist/settings-command.js +1 -4
- package/dist/tools/shell.js +16 -11
- package/dist/tui/app-workflows.js +5 -2
- package/dist/tui/app.js +13 -5
- package/dist/tui/approve.js +21 -10
- package/dist/tui/blocks.js +12 -4
- package/dist/tui/components/command-menu.js +9 -6
- package/dist/tui/components/composer.js +5 -4
- package/dist/tui/components/menu.js +11 -6
- package/dist/tui/components/prompt.js +67 -0
- package/dist/tui/components/status.js +1 -8
- package/dist/tui/components/tool.js +103 -45
- package/dist/tui/field.js +8 -32
- package/dist/tui/modal.js +6 -4
- package/dist/tui/picker.js +50 -26
- package/dist/tui/transcript-view.js +17 -13
- package/dist/tui/turn.js +58 -18
- package/dist/tui/view.js +8 -10
- package/dist/ui/render.js +12 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,8 +164,9 @@ Useful controls:
|
|
|
164
164
|
the place you are reading.
|
|
165
165
|
- **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
|
|
166
166
|
|
|
167
|
-
The one-line footer keeps model, effort, and workspace on the left. Live work
|
|
168
|
-
|
|
167
|
+
The one-line footer keeps model, effort, and workspace on the left. Live work
|
|
168
|
+
stays visible on the reasoning and tool rail; the right edge carries the
|
|
169
|
+
interrupt hint, readiness guidance, and temporary feedback without polluting
|
|
169
170
|
the transcript.
|
|
170
171
|
|
|
171
172
|
## Configuration
|
|
@@ -253,8 +254,9 @@ npm ci --ignore-scripts
|
|
|
253
254
|
npm run check
|
|
254
255
|
~~~
|
|
255
256
|
|
|
256
|
-
The visual lab exercises the
|
|
257
|
-
|
|
257
|
+
The visual lab exercises the complete production TUI—golden conversation,
|
|
258
|
+
live tool trace, output tails, change-centric diffs, approvals, menus, and
|
|
259
|
+
fields—without a provider, network access, tool execution, or workspace writes:
|
|
258
260
|
|
|
259
261
|
~~~console
|
|
260
262
|
npm run tui:lab
|
package/dist/commands.js
CHANGED
|
@@ -121,7 +121,6 @@ async function permissions(session, host) {
|
|
|
121
121
|
}
|
|
122
122
|
const index = await choose({
|
|
123
123
|
title: heading("revoke permission", "applies only to this session", session.palette),
|
|
124
|
-
right: "↑↓ enter · esc close",
|
|
125
124
|
options: [
|
|
126
125
|
...entries.map((entry) => ({ label: entry.label, hint: "revoke" })),
|
|
127
126
|
{ label: "all remembered permissions", hint: "revoke all" },
|
package/dist/controller.js
CHANGED
|
@@ -62,7 +62,12 @@ async function settle(call, options, events, signal, preview) {
|
|
|
62
62
|
if (tool.dangerous && !(await events.approve(call))) {
|
|
63
63
|
return refuse(call, "the user declined this call — ask them how to proceed", "declined");
|
|
64
64
|
}
|
|
65
|
-
return runTool(tool, call, {
|
|
65
|
+
return runTool(tool, call, {
|
|
66
|
+
...options.toolContext,
|
|
67
|
+
signal,
|
|
68
|
+
preview,
|
|
69
|
+
onOutput: (output) => events.onToolOutput?.(call, output),
|
|
70
|
+
});
|
|
66
71
|
}
|
|
67
72
|
function refuse(call, reason, summary) {
|
|
68
73
|
return {
|
|
@@ -22,7 +22,6 @@ export async function askForKey(name, host, pal) {
|
|
|
22
22
|
return false;
|
|
23
23
|
const index = await host.choose({
|
|
24
24
|
title: heading("remember it?", name, pal),
|
|
25
|
-
right: "↑↓ enter",
|
|
26
25
|
options: [
|
|
27
26
|
{ label: "just this session", hint: "nothing is written", key: "s" },
|
|
28
27
|
{ label: `save it to ${storeLabel()}`, hint: "owner-only file", key: "w" },
|
|
@@ -55,7 +54,6 @@ export async function credentialsCommand(session, host) {
|
|
|
55
54
|
return;
|
|
56
55
|
const index = await choose({
|
|
57
56
|
title: heading("credential", "values are never shown", session.palette),
|
|
58
|
-
right: "↑↓ enter · esc close",
|
|
59
57
|
options: PROVIDERS.map((provider) => ({
|
|
60
58
|
label: provider.keyVar,
|
|
61
59
|
hint: credentialSource(provider.keyVar) ?? "missing",
|
|
@@ -86,7 +84,6 @@ export async function credentialsCommand(session, host) {
|
|
|
86
84
|
];
|
|
87
85
|
const action = await choose({
|
|
88
86
|
title: heading(name, source ?? "missing", session.palette),
|
|
89
|
-
right: "↑↓ enter",
|
|
90
87
|
options: actions,
|
|
91
88
|
index: 0,
|
|
92
89
|
});
|
|
@@ -103,7 +100,6 @@ async function offerForget(name, session, host, hint) {
|
|
|
103
100
|
return;
|
|
104
101
|
const index = await host.choose({
|
|
105
102
|
title: heading(name, hint, session.palette),
|
|
106
|
-
right: "↑↓ enter",
|
|
107
103
|
options: [
|
|
108
104
|
{ label: "keep saved copy", key: "k" },
|
|
109
105
|
{ label: "forget saved copy", hint: storeLabel(), key: "f" },
|
|
@@ -20,7 +20,6 @@ export async function ollamaConnectionSetting(session, host, persist) {
|
|
|
20
20
|
const current = ollamaConnection();
|
|
21
21
|
const index = await host.choose({
|
|
22
22
|
title: heading("Ollama connection", "where Ollama requests run", session.palette),
|
|
23
|
-
right: "↑↓ enter · esc back",
|
|
24
23
|
options: [
|
|
25
24
|
{ label: "cloud", hint: "ollama.com · API key" },
|
|
26
25
|
{ label: "local", hint: "this computer · no API key" },
|
|
@@ -23,7 +23,6 @@ export async function providersCommand(session, host, behavior = {}) {
|
|
|
23
23
|
const at = PROVIDERS.findIndex((provider) => provider.id === session.provider.id);
|
|
24
24
|
const index = await choose({
|
|
25
25
|
title: heading("provider", "where the next turn runs", session.palette),
|
|
26
|
-
right: "↑↓ enter",
|
|
27
26
|
options,
|
|
28
27
|
index: Math.max(0, at),
|
|
29
28
|
});
|
|
@@ -149,7 +148,6 @@ export async function modelsCommand(session, host, behavior = {}) {
|
|
|
149
148
|
}
|
|
150
149
|
const index = await choose({
|
|
151
150
|
title: heading("model", provider.id, session.palette),
|
|
152
|
-
right: "↑↓ enter",
|
|
153
151
|
options: ids.map((id) => ({ label: id })),
|
|
154
152
|
searchable: true,
|
|
155
153
|
query: "",
|
package/dist/settings-command.js
CHANGED
|
@@ -59,8 +59,7 @@ export async function settingsCommand(session, host) {
|
|
|
59
59
|
export function settingsPicker(values, pal, index = 0, store = settingsLabel()) {
|
|
60
60
|
return {
|
|
61
61
|
title: heading("settings", store, pal),
|
|
62
|
-
|
|
63
|
-
footer: "Changes apply now · flags and environment win at launch",
|
|
62
|
+
description: "Changes apply now · flags and environment win at launch",
|
|
64
63
|
options: settingsItems(values).map((item) => item.option),
|
|
65
64
|
index,
|
|
66
65
|
};
|
|
@@ -135,7 +134,6 @@ async function effortSetting(session, host) {
|
|
|
135
134
|
const current = session.config.effort;
|
|
136
135
|
const index = await choose({
|
|
137
136
|
title: heading("effort", "saved default", session.palette),
|
|
138
|
-
right: "↑↓ enter · esc back",
|
|
139
137
|
options: EFFORTS.map((value) => ({ label: value })),
|
|
140
138
|
index: Math.max(0, EFFORTS.findIndex((value) => value === current)),
|
|
141
139
|
});
|
|
@@ -152,7 +150,6 @@ async function motionSetting(session, host) {
|
|
|
152
150
|
const values = [false, true];
|
|
153
151
|
const index = await choose({
|
|
154
152
|
title: heading("reduced motion", "saved default", session.palette),
|
|
155
|
-
right: "↑↓ enter · esc back",
|
|
156
153
|
options: values.map((value) => ({ label: value ? "on" : "off" })),
|
|
157
154
|
index: session.config.reducedMotion ? 1 : 0,
|
|
158
155
|
});
|
package/dist/tools/shell.js
CHANGED
|
@@ -24,7 +24,7 @@ export const runCommand = {
|
|
|
24
24
|
const timeoutMs = optionalInt(args, "timeout_ms") ?? DEFAULT_TIMEOUT_MS;
|
|
25
25
|
if (timeoutMs <= 0)
|
|
26
26
|
throw new Error('"timeout_ms" must be a positive integer');
|
|
27
|
-
const result = await execute(command, ctx.root, timeoutMs, ctx.signal);
|
|
27
|
+
const result = await execute(command, ctx.root, timeoutMs, ctx.signal, ctx.onOutput);
|
|
28
28
|
const output = redactCredentials(result.output);
|
|
29
29
|
const summary = result.timedOut
|
|
30
30
|
? `timed out after ${timeoutMs}ms`
|
|
@@ -35,7 +35,7 @@ export const runCommand = {
|
|
|
35
35
|
};
|
|
36
36
|
},
|
|
37
37
|
};
|
|
38
|
-
function execute(command, cwd, timeoutMs, signal) {
|
|
38
|
+
function execute(command, cwd, timeoutMs, signal, onOutput) {
|
|
39
39
|
if (signal?.aborted === true)
|
|
40
40
|
return Promise.reject(abortReason(signal));
|
|
41
41
|
return new Promise((resolve, reject) => {
|
|
@@ -46,7 +46,7 @@ function execute(command, cwd, timeoutMs, signal) {
|
|
|
46
46
|
windowsHide: true,
|
|
47
47
|
detached: process.platform !== "win32",
|
|
48
48
|
});
|
|
49
|
-
const output = capture();
|
|
49
|
+
const output = capture(onOutput);
|
|
50
50
|
let timedOut = false;
|
|
51
51
|
let aborted;
|
|
52
52
|
let settled = false;
|
|
@@ -91,30 +91,35 @@ function execute(command, cwd, timeoutMs, signal) {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
|
-
function capture() {
|
|
94
|
+
function capture(onOutput) {
|
|
95
95
|
const half = MAX_OUTPUT_CHARS / 2;
|
|
96
96
|
const credentials = credentialRedactor();
|
|
97
97
|
let head = "";
|
|
98
98
|
let tail = "";
|
|
99
99
|
let total = 0;
|
|
100
|
-
const
|
|
100
|
+
const appendSafe = (chunk) => {
|
|
101
101
|
total += chunk.length;
|
|
102
102
|
const room = Math.max(0, half - head.length);
|
|
103
103
|
head += chunk.slice(0, room);
|
|
104
104
|
const rest = chunk.slice(room);
|
|
105
105
|
if (rest !== "")
|
|
106
106
|
tail = `${tail}${rest}`.slice(-half);
|
|
107
|
+
if (chunk !== "")
|
|
108
|
+
onOutput?.(formatted().trimEnd());
|
|
109
|
+
};
|
|
110
|
+
const formatted = () => {
|
|
111
|
+
if (total <= MAX_OUTPUT_CHARS)
|
|
112
|
+
return `${head}${tail}`;
|
|
113
|
+
const cut = total - head.length - tail.length;
|
|
114
|
+
return `${head}\n\n[... ${cut} characters cut ...]\n\n${tail}`;
|
|
107
115
|
};
|
|
108
116
|
return {
|
|
109
117
|
append(chunk) {
|
|
110
|
-
|
|
118
|
+
appendSafe(credentials.write(chunk));
|
|
111
119
|
},
|
|
112
120
|
value() {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return `${head}${tail}`;
|
|
116
|
-
const cut = total - head.length - tail.length;
|
|
117
|
-
return `${head}\n\n[... ${cut} characters cut ...]\n\n${tail}`;
|
|
121
|
+
appendSafe(credentials.end());
|
|
122
|
+
return formatted();
|
|
118
123
|
},
|
|
119
124
|
};
|
|
120
125
|
}
|
|
@@ -90,14 +90,17 @@ export function appWorkflows(options) {
|
|
|
90
90
|
},
|
|
91
91
|
usage: (usage) => recordUsage(session.usage, usage),
|
|
92
92
|
});
|
|
93
|
+
let finishReason;
|
|
93
94
|
try {
|
|
94
95
|
await runTurn(session.history, controllerOptions(session), events, activity.control.signal);
|
|
95
96
|
}
|
|
96
97
|
catch (error) {
|
|
97
|
-
|
|
98
|
+
const interrupted = activity.control.signal.aborted;
|
|
99
|
+
finishReason = interrupted ? "interrupted" : "failed";
|
|
100
|
+
options.emit(turnFailure(session, error, interrupted));
|
|
98
101
|
}
|
|
99
102
|
finally {
|
|
100
|
-
events.finish();
|
|
103
|
+
events.finish(finishReason);
|
|
101
104
|
options.finishActivity(activity);
|
|
102
105
|
}
|
|
103
106
|
}
|
package/dist/tui/app.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Everything mutable about a session lives in `state` here. The view is a pure
|
|
4
4
|
// function of it, so the only job of a key handler is to change state and ask
|
|
5
5
|
// for a repaint — never to draw.
|
|
6
|
-
import { begin
|
|
6
|
+
import { begin } from "./activity.js";
|
|
7
7
|
import * as overlay from "./overlay.js";
|
|
8
8
|
import { options as completionOptions } from "./complete.js";
|
|
9
9
|
import { decoder } from "./keys.js";
|
|
@@ -50,13 +50,12 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
50
50
|
unseen: state.unseen,
|
|
51
51
|
pal: session.palette,
|
|
52
52
|
footer: footerInfo(session, workspace),
|
|
53
|
-
status: state.status
|
|
54
|
-
? state.status
|
|
55
|
-
: `${state.status} · ${elapsed(state.activity)}`,
|
|
53
|
+
status: state.status,
|
|
56
54
|
feedback: state.feedback,
|
|
57
55
|
readiness: turnBlocker(session),
|
|
58
56
|
spin: state.spin,
|
|
59
57
|
reducedMotion: session.config.reducedMotion,
|
|
58
|
+
now: Date.now(),
|
|
60
59
|
modal: overlay.shown(state.open),
|
|
61
60
|
menu: completionOptions(state.completing),
|
|
62
61
|
menuIndex: state.completing?.index,
|
|
@@ -147,7 +146,15 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
147
146
|
spinTimer = setInterval(() => {
|
|
148
147
|
if (!session.config.reducedMotion)
|
|
149
148
|
state.spin++;
|
|
150
|
-
|
|
149
|
+
let activeTool;
|
|
150
|
+
for (let index = state.blocks.length - 1; index >= 0; index--) {
|
|
151
|
+
const block = state.blocks[index];
|
|
152
|
+
if (block?.kind !== "tool" || block.tone !== "pending" || block.startedAt === undefined)
|
|
153
|
+
continue;
|
|
154
|
+
activeTool = block;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
render(activeTool);
|
|
151
158
|
}, session.config.reducedMotion ? 1_000 : SPIN_MS);
|
|
152
159
|
render();
|
|
153
160
|
return activity;
|
|
@@ -178,6 +185,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
178
185
|
refreshSettings: () => {
|
|
179
186
|
terminal.setReducedMotion(session.config.reducedMotion);
|
|
180
187
|
paint.invalidate();
|
|
188
|
+
transcript.invalidate();
|
|
181
189
|
render();
|
|
182
190
|
},
|
|
183
191
|
startActivity,
|
package/dist/tui/approve.js
CHANGED
|
@@ -29,22 +29,33 @@ export function scopeFor(call) {
|
|
|
29
29
|
export function promptFor(call, target, pal) {
|
|
30
30
|
const scope = scopeFor(call);
|
|
31
31
|
const options = [
|
|
32
|
-
{ label:
|
|
33
|
-
{ label: `
|
|
34
|
-
{ label: "
|
|
32
|
+
{ label: "Yes, once", hint: "enter", key: "y" },
|
|
33
|
+
{ label: `Yes, ${scopeNoun(scope)} for the session`, hint: "a", key: "a" },
|
|
34
|
+
{ label: "No, and say why", hint: "esc", key: "n" },
|
|
35
35
|
];
|
|
36
36
|
return {
|
|
37
|
-
title: [
|
|
38
|
-
|
|
39
|
-
{ text: ` ${call.name}`, fg: pal.ink.muted },
|
|
40
|
-
...(target === "" ? [] : [{ text: ` ${target}`, fg: pal.ink.muted }]),
|
|
41
|
-
],
|
|
42
|
-
description: "Review the exact action above, then choose.",
|
|
43
|
-
footer: "Enter to select · Esc to deny",
|
|
37
|
+
title: [{ text: question(call.name), fg: pal.ink.attention, bold: true }],
|
|
38
|
+
right: `${call.name}${target === "" ? "" : ` · ${target}`}`,
|
|
44
39
|
options,
|
|
45
40
|
index: 0,
|
|
46
41
|
};
|
|
47
42
|
}
|
|
43
|
+
function question(name) {
|
|
44
|
+
if (name === "edit_file")
|
|
45
|
+
return "Allow this edit?";
|
|
46
|
+
if (name === "write_file")
|
|
47
|
+
return "Write this file?";
|
|
48
|
+
if (name === "run_command")
|
|
49
|
+
return "Run this command?";
|
|
50
|
+
return "Allow this call?";
|
|
51
|
+
}
|
|
52
|
+
function scopeNoun(scope) {
|
|
53
|
+
if (scope.key.startsWith("file\0"))
|
|
54
|
+
return "this file";
|
|
55
|
+
if (scope.key.startsWith("command\0"))
|
|
56
|
+
return "this command";
|
|
57
|
+
return "this call";
|
|
58
|
+
}
|
|
48
59
|
function stable(value) {
|
|
49
60
|
if (Array.isArray(value))
|
|
50
61
|
return `[${value.map(stable).join(",")}]`;
|
package/dist/tui/blocks.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { renderAnswer, renderReasoning, renderUser } from "./components/messages.js";
|
|
3
3
|
import { renderList, renderNotice } from "./components/misc.js";
|
|
4
4
|
import { renderTool } from "./components/tool.js";
|
|
5
|
-
export function render(block, width, pal) {
|
|
5
|
+
export function render(block, width, pal, context = {}) {
|
|
6
6
|
switch (block.kind) {
|
|
7
7
|
case "user":
|
|
8
8
|
return renderUser(block, width, pal);
|
|
@@ -11,13 +11,21 @@ export function render(block, width, pal) {
|
|
|
11
11
|
case "reasoning":
|
|
12
12
|
return renderReasoning(block, width, pal);
|
|
13
13
|
case "tool":
|
|
14
|
-
return renderTool(block, width, pal
|
|
14
|
+
return renderTool(block, width, pal, {
|
|
15
|
+
continues: context.previous?.kind === "tool",
|
|
16
|
+
spin: context.spin,
|
|
17
|
+
reducedMotion: context.reducedMotion,
|
|
18
|
+
now: context.now,
|
|
19
|
+
});
|
|
15
20
|
case "notice":
|
|
16
21
|
return renderNotice(block, width, pal);
|
|
17
22
|
case "list":
|
|
18
23
|
return renderList(block, width, pal);
|
|
19
24
|
}
|
|
20
25
|
}
|
|
21
|
-
export function renderAll(blocks, width, pal) {
|
|
22
|
-
return blocks.flatMap((block) => render(block, width, pal
|
|
26
|
+
export function renderAll(blocks, width, pal, context = {}) {
|
|
27
|
+
return blocks.flatMap((block, index) => render(block, width, pal, {
|
|
28
|
+
...context,
|
|
29
|
+
previous: blocks[index - 1],
|
|
30
|
+
}));
|
|
23
31
|
}
|
|
@@ -5,12 +5,15 @@ export function renderCommandMenu(commands, selected, width, pal) {
|
|
|
5
5
|
const { first, last } = menuWindow(commands.length, selectedIndex, MAX_ROWS);
|
|
6
6
|
const shown = commands.slice(first, last);
|
|
7
7
|
if (shown.length === 0)
|
|
8
|
-
return [];
|
|
9
|
-
return
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
return { rows: [], right: "" };
|
|
9
|
+
return {
|
|
10
|
+
rows: renderMenuRows(shown.map((command, index) => ({
|
|
11
|
+
label: `/${command.name}`,
|
|
12
|
+
description: command.blurb,
|
|
13
|
+
selected: selectedIndex === first + index,
|
|
14
|
+
})), width, pal),
|
|
15
|
+
right: commands.length > shown.length ? `${first + 1}–${last} / ${commands.length}` : "",
|
|
16
|
+
};
|
|
14
17
|
}
|
|
15
18
|
export function commandMenuLimit() {
|
|
16
19
|
return MAX_ROWS;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { row } from "../../ui/render.js";
|
|
2
|
-
import { charWidth, graphemes } from "../../ui/width.js";
|
|
3
|
-
export function renderComposer(editor, width, maxInputRows, pal) {
|
|
4
|
-
const
|
|
2
|
+
import { charWidth, graphemes, textWidth } from "../../ui/width.js";
|
|
3
|
+
export function renderComposer(editor, width, maxInputRows, pal, right = "") {
|
|
4
|
+
const rightRoom = right === "" ? 0 : textWidth(right) + 1;
|
|
5
|
+
const laid = layout(editor, Math.max(1, width - rightRoom));
|
|
5
6
|
const room = Math.max(1, maxInputRows);
|
|
6
7
|
const first = Math.max(0, Math.min(laid.lines.length - room, laid.cursor.row));
|
|
7
8
|
const shown = laid.lines.slice(first, first + room);
|
|
8
9
|
return {
|
|
9
|
-
rows: shown.map((line) => row(width, [{ text: line, fg: pal.ink.bright }])),
|
|
10
|
+
rows: shown.map((line, index) => row(width, [{ text: line, fg: pal.ink.bright }], index === 0 && right !== "" ? [{ text: right, fg: pal.ink.muted }] : [])),
|
|
10
11
|
cursor: { row: laid.cursor.row - first, col: laid.cursor.col },
|
|
11
12
|
};
|
|
12
13
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// One visual row grammar for autocomplete and every interactive selector.
|
|
2
|
-
import { plainLen, row } from "../../ui/render.js";
|
|
2
|
+
import { hasColor, plainLen, row } from "../../ui/render.js";
|
|
3
3
|
import { elide } from "../../ui/width.js";
|
|
4
4
|
export function renderMenuRows(entries, width, pal) {
|
|
5
5
|
if (entries.length === 0)
|
|
@@ -16,22 +16,27 @@ export function menuWindow(length, selected, visible) {
|
|
|
16
16
|
return { first, last: Math.min(count, first + room) };
|
|
17
17
|
}
|
|
18
18
|
function renderEntry(entry, labelWidth, width, pal) {
|
|
19
|
-
|
|
19
|
+
// Colour terminals use one quiet selection band. In monochrome the arrow is
|
|
20
|
+
// structural rather than decorative, so selection remains unambiguous.
|
|
21
|
+
const monochrome = !hasColor();
|
|
22
|
+
const selectedMark = entry.selected && monochrome ? "→ " : " ";
|
|
23
|
+
const fg = entry.selected ? pal.ink.bright : pal.ink.fg;
|
|
20
24
|
const primary = [
|
|
21
|
-
{ text: entry.selected ?
|
|
25
|
+
{ text: selectedMark, fg: entry.selected ? pal.accent : fg },
|
|
22
26
|
{ text: entry.label, fg },
|
|
23
27
|
];
|
|
24
28
|
if (width > 40 && entry.description !== undefined) {
|
|
25
29
|
const gap = Math.max(2, labelWidth - primaryWidth(entry));
|
|
26
|
-
primary.push({ text: `${" ".repeat(gap)}${entry.description}`, fg: entry.selected ? pal.
|
|
30
|
+
primary.push({ text: `${" ".repeat(gap)}${entry.description}`, fg: entry.selected ? pal.ink.fg : pal.ink.muted });
|
|
27
31
|
}
|
|
28
32
|
const right = width > 40 && entry.hint !== undefined
|
|
29
33
|
? [{
|
|
30
34
|
text: elide(entry.hint, Math.max(1, Math.floor(width / 4))),
|
|
31
|
-
fg: entry.selected ? pal.
|
|
35
|
+
fg: entry.selected ? pal.ink.fg : pal.ink.muted,
|
|
32
36
|
}]
|
|
33
37
|
: [];
|
|
34
|
-
|
|
38
|
+
const ground = entry.selected && !monochrome ? pal.surface.inset : undefined;
|
|
39
|
+
return row(width, primary, right, ground);
|
|
35
40
|
}
|
|
36
41
|
function primaryWidth(entry) {
|
|
37
42
|
return plainLen([{ text: entry.label }]);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// One active one-line input grammar for dock fields and searchable pickers.
|
|
2
|
+
import { row } from "../../ui/render.js";
|
|
3
|
+
import { charWidth, graphemes, textWidth } from "../../ui/width.js";
|
|
4
|
+
const MARK = "→ ";
|
|
5
|
+
const DOT = "●";
|
|
6
|
+
export const PROMPT_WIDTH = textWidth(MARK);
|
|
7
|
+
export function promptLine(text, cursor, width, pal, options = {}) {
|
|
8
|
+
const right = options.right === undefined || options.right === ""
|
|
9
|
+
? []
|
|
10
|
+
: [{ text: options.right, fg: pal.ink.muted }];
|
|
11
|
+
const laid = layout(text, cursor, width, options);
|
|
12
|
+
const content = laid.visible === "" && options.placeholder !== undefined
|
|
13
|
+
? { text: options.placeholder, fg: pal.ink.muted }
|
|
14
|
+
: { text: laid.visible, fg: pal.ink.bright };
|
|
15
|
+
return {
|
|
16
|
+
row: row(width, [{ text: MARK, fg: pal.accent }, content], right),
|
|
17
|
+
cursor: { row: 0, col: PROMPT_WIDTH + textWidth(laid.before) },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function promptCursor(text, cursor, width, options = {}) {
|
|
21
|
+
const laid = layout(text, cursor, width, options);
|
|
22
|
+
return {
|
|
23
|
+
row: 0,
|
|
24
|
+
col: PROMPT_WIDTH + textWidth(laid.before),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function layout(text, cursor, width, options) {
|
|
28
|
+
const rightRoom = options.right === undefined || options.right === ""
|
|
29
|
+
? 0
|
|
30
|
+
: textWidth(options.right) + 1;
|
|
31
|
+
const room = Math.max(1, width - PROMPT_WIDTH - rightRoom);
|
|
32
|
+
const source = graphemes(text);
|
|
33
|
+
const shown = options.secret === true ? source.map(() => DOT) : source;
|
|
34
|
+
const at = graphemes(text.slice(0, cursor)).length;
|
|
35
|
+
const start = visibleStart(shown, at, room);
|
|
36
|
+
return {
|
|
37
|
+
visible: visibleSlice(shown, start, room),
|
|
38
|
+
before: shown.slice(start, Math.max(start, at)).join(""),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Keep one terminal cell available for the caret at the end of the window. */
|
|
42
|
+
function visibleStart(clusters, cursor, room) {
|
|
43
|
+
const budget = Math.max(0, room - 1);
|
|
44
|
+
let used = 0;
|
|
45
|
+
let start = Math.max(0, Math.min(clusters.length, cursor));
|
|
46
|
+
while (start > 0) {
|
|
47
|
+
const width = charWidth(clusters[start - 1]);
|
|
48
|
+
if (used + width > budget)
|
|
49
|
+
break;
|
|
50
|
+
start--;
|
|
51
|
+
used += width;
|
|
52
|
+
}
|
|
53
|
+
return start;
|
|
54
|
+
}
|
|
55
|
+
function visibleSlice(clusters, start, room) {
|
|
56
|
+
let used = 0;
|
|
57
|
+
let out = "";
|
|
58
|
+
for (let index = start; index < clusters.length; index++) {
|
|
59
|
+
const cluster = clusters[index];
|
|
60
|
+
const width = charWidth(cluster);
|
|
61
|
+
if (used + width > room)
|
|
62
|
+
break;
|
|
63
|
+
out += cluster;
|
|
64
|
+
used += width;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
-
export function spinner(tick) {
|
|
3
|
-
return SPINNER[tick % SPINNER.length];
|
|
4
|
-
}
|
|
5
1
|
/** Styled content for the footer's replaceable right-hand status channel. */
|
|
6
2
|
export function renderStatus(info, pal) {
|
|
7
3
|
const urgent = info.feedback?.tone === "warn" || info.feedback?.tone === "error"
|
|
@@ -10,10 +6,7 @@ export function renderStatus(info, pal) {
|
|
|
10
6
|
if (urgent !== undefined)
|
|
11
7
|
return withUnseen(feedbackSegments(urgent, pal), info.unseen, pal);
|
|
12
8
|
if (info.status !== undefined) {
|
|
13
|
-
return withUnseen([
|
|
14
|
-
{ text: `${info.reducedMotion ? "•" : spinner(info.tick)} `, fg: pal.accent },
|
|
15
|
-
{ text: `${info.status} (esc to interrupt)`, fg: pal.ink.muted },
|
|
16
|
-
], info.unseen, pal);
|
|
9
|
+
return withUnseen([{ text: "esc to interrupt", fg: pal.ink.muted }], info.unseen, pal);
|
|
17
10
|
}
|
|
18
11
|
if (info.feedback !== undefined) {
|
|
19
12
|
return withUnseen(feedbackSegments(info.feedback, pal), info.unseen, pal);
|
|
@@ -1,39 +1,94 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const shown = block
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
"",
|
|
11
|
-
blank(width, ground),
|
|
1
|
+
// A compact execution trace: state and identity on one rail, evidence below.
|
|
2
|
+
import { hasColor, row } from "../../ui/render.js";
|
|
3
|
+
const OUTPUT_ROWS = 8;
|
|
4
|
+
const LIVE_OUTPUT_ROWS = 6;
|
|
5
|
+
const DIFF_ROWS = 12;
|
|
6
|
+
export function renderTool(block, width, pal, context = {}) {
|
|
7
|
+
const shown = visibleDetails(block);
|
|
8
|
+
const right = liveLabel(block, context);
|
|
9
|
+
return [
|
|
10
|
+
...(context.continues === true ? [] : [""]),
|
|
12
11
|
row(width, [
|
|
12
|
+
{ text: " " },
|
|
13
|
+
{ text: `${stateGlyph(block, context)} `, fg: statusInk(block.tone, pal), bold: true },
|
|
13
14
|
{ text: block.name, fg: pal.ink.bright, bold: true },
|
|
14
|
-
...(block.target === "" ? [] : [{ text: `
|
|
15
|
-
], [],
|
|
15
|
+
...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.accent }]),
|
|
16
|
+
], right === "" ? [] : [{ text: right, fg: statusInk(block.tone, pal) }]),
|
|
17
|
+
...shown.map((detail) => renderDetail(detail, block.tone, width, pal)),
|
|
16
18
|
];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
}
|
|
20
|
+
function visibleDetails(block) {
|
|
21
|
+
const all = block.body ?? [];
|
|
22
|
+
if (block.expanded === true || all.length === 0)
|
|
23
|
+
return all;
|
|
24
|
+
if (all.every((detail) => detail.kind === "out")) {
|
|
25
|
+
const limit = block.tone === "pending" ? LIVE_OUTPUT_ROWS : OUTPUT_ROWS;
|
|
26
|
+
if (all.length <= limit)
|
|
27
|
+
return all;
|
|
28
|
+
const hidden = all.length - limit;
|
|
29
|
+
const note = block.tone === "pending"
|
|
30
|
+
? `… ${all.length} lines so far`
|
|
31
|
+
: `… ${hidden} earlier lines · ctrl+o expand`;
|
|
32
|
+
return [{ kind: "gap", text: note }, ...all.slice(-limit)];
|
|
33
|
+
}
|
|
34
|
+
return diffWindow(all);
|
|
35
|
+
}
|
|
36
|
+
/** Keep change rows and their nearest context; never turn a diff into a tail. */
|
|
37
|
+
function diffWindow(all) {
|
|
38
|
+
if (all.length <= DIFF_ROWS)
|
|
39
|
+
return [...all];
|
|
40
|
+
const important = all.flatMap((detail, index) => detail.kind === "keep" ? [] : [index]);
|
|
41
|
+
const chosen = new Set();
|
|
42
|
+
const budgeted = important.length <= DIFF_ROWS
|
|
43
|
+
? important
|
|
44
|
+
: [
|
|
45
|
+
...important.slice(0, Math.ceil(DIFF_ROWS / 2)),
|
|
46
|
+
...important.slice(-Math.floor(DIFF_ROWS / 2)),
|
|
47
|
+
];
|
|
48
|
+
for (const index of budgeted)
|
|
49
|
+
chosen.add(index);
|
|
50
|
+
for (let radius = 1; chosen.size < DIFF_ROWS && radius < all.length; radius++) {
|
|
51
|
+
for (const index of important) {
|
|
52
|
+
for (const candidate of [index - radius, index + radius]) {
|
|
53
|
+
if (candidate < 0 || candidate >= all.length || chosen.has(candidate))
|
|
54
|
+
continue;
|
|
55
|
+
chosen.add(candidate);
|
|
56
|
+
if (chosen.size >= DIFF_ROWS)
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if (chosen.size >= DIFF_ROWS)
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
20
62
|
}
|
|
21
|
-
|
|
22
|
-
|
|
63
|
+
const indexes = [...chosen].sort((left, right) => left - right);
|
|
64
|
+
const out = [];
|
|
65
|
+
let previous = -1;
|
|
66
|
+
for (const index of indexes) {
|
|
67
|
+
if (index > previous + 1) {
|
|
68
|
+
out.push({ kind: "gap", text: `… ${index - previous - 1} lines hidden · ctrl+o expand` });
|
|
69
|
+
}
|
|
70
|
+
const detail = all[index];
|
|
71
|
+
if (detail !== undefined)
|
|
72
|
+
out.push(detail);
|
|
73
|
+
previous = index;
|
|
23
74
|
}
|
|
24
|
-
if (
|
|
25
|
-
|
|
75
|
+
if (previous < all.length - 1) {
|
|
76
|
+
out.push({
|
|
77
|
+
kind: "gap",
|
|
78
|
+
text: `… ${all.length - previous - 1} lines hidden · ctrl+o expand`,
|
|
79
|
+
});
|
|
26
80
|
}
|
|
27
|
-
|
|
28
|
-
return rows;
|
|
81
|
+
return out;
|
|
29
82
|
}
|
|
30
|
-
function renderDetail(detail, tone, width,
|
|
83
|
+
function renderDetail(detail, tone, width, pal) {
|
|
84
|
+
const lead = { text: " " };
|
|
85
|
+
const rail = { text: "│ ", fg: pal.rule };
|
|
31
86
|
if (detail.kind === "out") {
|
|
32
87
|
const fg = tone === "fail" || failureLine(detail.text) ? pal.ink.removed : pal.ink.muted;
|
|
33
|
-
return row(width, [{ text: detail.text === "" ? " " : detail.text, fg }]
|
|
88
|
+
return row(width, [lead, rail, { text: detail.text === "" ? " " : detail.text, fg }]);
|
|
34
89
|
}
|
|
35
90
|
if (detail.kind === "gap") {
|
|
36
|
-
return row(width, [{ text:
|
|
91
|
+
return row(width, [lead, rail, { text: detail.text, fg: pal.ink.muted, italic: true }]);
|
|
37
92
|
}
|
|
38
93
|
const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
|
|
39
94
|
const prefix = `${detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " "}${String(number ?? "").padStart(3)} `;
|
|
@@ -42,7 +97,7 @@ function renderDetail(detail, tone, width, ground, pal) {
|
|
|
42
97
|
: detail.kind === "del"
|
|
43
98
|
? pal.ink.removed
|
|
44
99
|
: pal.ink.muted;
|
|
45
|
-
return row(width, [{ text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)]
|
|
100
|
+
return row(width, [lead, rail, { text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)]);
|
|
46
101
|
}
|
|
47
102
|
function emphasized(text, emphasis, fg) {
|
|
48
103
|
if (emphasis === undefined || emphasis.length <= 0)
|
|
@@ -55,31 +110,34 @@ function emphasized(text, emphasis, fg) {
|
|
|
55
110
|
...(end === text.length ? [] : [{ text: text.slice(end), fg }]),
|
|
56
111
|
];
|
|
57
112
|
}
|
|
58
|
-
function
|
|
59
|
-
if (tone === "
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
113
|
+
function stateGlyph(block, context) {
|
|
114
|
+
if (block.tone === "pending") {
|
|
115
|
+
if (block.startedAt === undefined || context.reducedMotion === true)
|
|
116
|
+
return "◌";
|
|
117
|
+
const frames = ["◐", "◓", "◑", "◒"];
|
|
118
|
+
return frames[Math.floor((context.spin ?? 0) / 2) % frames.length];
|
|
119
|
+
}
|
|
120
|
+
if (block.tone === "fail")
|
|
121
|
+
return hasColor() ? "●" : "×";
|
|
122
|
+
if (block.tone === "deny")
|
|
123
|
+
return "○";
|
|
124
|
+
return hasColor() ? "●" : "✓";
|
|
125
|
+
}
|
|
126
|
+
function liveLabel(block, context) {
|
|
127
|
+
if (block.tone !== "pending" || block.startedAt === undefined || block.right !== "running") {
|
|
128
|
+
return block.right;
|
|
129
|
+
}
|
|
130
|
+
const elapsed = Math.max(0, (context.now ?? Date.now()) - block.startedAt);
|
|
131
|
+
const seconds = elapsed / 1_000;
|
|
132
|
+
return `running · ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
|
|
66
133
|
}
|
|
67
134
|
function statusInk(tone, pal) {
|
|
68
135
|
if (tone === "fail")
|
|
69
136
|
return pal.ink.removed;
|
|
70
|
-
if (tone === "pending")
|
|
137
|
+
if (tone === "pending" || tone === "deny")
|
|
71
138
|
return pal.ink.attention;
|
|
72
139
|
return pal.ink.muted;
|
|
73
140
|
}
|
|
74
141
|
function failureLine(line) {
|
|
75
|
-
return line.startsWith("✖") || line.includes("AssertionError") || /\bfailed\b/i.test(line);
|
|
76
|
-
}
|
|
77
|
-
function resultLabel(result, tone) {
|
|
78
|
-
if (tone === "pending")
|
|
79
|
-
return result;
|
|
80
|
-
const match = /^(.*?)(?:\s*·\s*)?(\d+(?:\.\d+)?(?:ms|s))$/.exec(result);
|
|
81
|
-
if (match === null)
|
|
82
|
-
return result;
|
|
83
|
-
const prefix = (match[1] ?? "").trim();
|
|
84
|
-
return `${prefix === "" ? "" : `${prefix} · `}Took ${match[2]}`;
|
|
142
|
+
return line.startsWith("✖") || line.startsWith("×") || line.includes("AssertionError") || /\bfailed\b/i.test(line);
|
|
85
143
|
}
|
package/dist/tui/field.js
CHANGED
|
@@ -4,46 +4,22 @@
|
|
|
4
4
|
// — the same `Editor`, the same keymap — and adds the two things a composer
|
|
5
5
|
// must never do: hide what it holds, and stay on one row however long that is.
|
|
6
6
|
import { row } from "../ui/render.js";
|
|
7
|
-
import {
|
|
8
|
-
const DOT = "●";
|
|
9
|
-
const PROMPT = "→ ";
|
|
10
|
-
const LEAD = textWidth(PROMPT);
|
|
7
|
+
import { promptCursor, promptLine } from "./components/prompt.js";
|
|
11
8
|
export function panel(field, width, pal) {
|
|
12
9
|
const { ink } = pal;
|
|
13
10
|
const head = row(width, field.title, field.right === undefined ? [] : [{ text: field.right, fg: ink.muted }]);
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{ text: view.text, fg: ink.bright },
|
|
18
|
-
]);
|
|
11
|
+
const line = promptLine(field.editor.text, field.editor.cursor, width, pal, {
|
|
12
|
+
secret: field.secret,
|
|
13
|
+
}).row;
|
|
19
14
|
const note = field.note === undefined ? [] : [row(width, [{ text: ` ${field.note}`, fg: ink.muted }])];
|
|
20
15
|
return [head, line, ...note];
|
|
21
16
|
}
|
|
22
17
|
/** Where the caret sits, relative to the unframed field body. */
|
|
23
18
|
export function caret(field, width) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
* A key is longer than most terminals are wide, so the line scrolls under a
|
|
30
|
-
* fixed window rather than wrapping: an input that grows downwards pushes the
|
|
31
|
-
* rest of the dock around while it is being pasted into.
|
|
32
|
-
*/
|
|
33
|
-
function laid(field, width) {
|
|
34
|
-
const inner = Math.max(1, width - LEAD);
|
|
35
|
-
const all = field.secret ? DOT.repeat(size(field.editor.text)) : field.editor.text;
|
|
36
|
-
const at = size(field.editor.text.slice(0, field.editor.cursor));
|
|
37
|
-
// Keep the caret in view, and prefer showing the end of what was typed —
|
|
38
|
-
// which for a pasted key is the half that says the paste arrived whole.
|
|
39
|
-
const start = Math.max(0, at - inner + 1);
|
|
40
|
-
return { text: [...all].slice(start, start + inner).join(""), col: at - start };
|
|
41
|
-
}
|
|
42
|
-
function size(text) {
|
|
43
|
-
let n = 0;
|
|
44
|
-
for (const _cluster of graphemes(text))
|
|
45
|
-
n++;
|
|
46
|
-
return n;
|
|
19
|
+
const at = promptCursor(field.editor.text, field.editor.cursor, width, {
|
|
20
|
+
secret: field.secret,
|
|
21
|
+
});
|
|
22
|
+
return { row: 1, col: at.col };
|
|
47
23
|
}
|
|
48
24
|
/**
|
|
49
25
|
* The line with its breaks taken out.
|
package/dist/tui/modal.js
CHANGED
|
@@ -16,9 +16,11 @@ export function panel(modal, width, pal, maxRows) {
|
|
|
16
16
|
/**
|
|
17
17
|
* Where the caret goes while this is open, if it goes anywhere.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Menus without search hide the caret; searchable menus expose the shared
|
|
20
|
+
* query prompt and place the terminal caret at its real editing position.
|
|
21
21
|
*/
|
|
22
|
-
export function caret(modal, width) {
|
|
23
|
-
return modal.kind === "type"
|
|
22
|
+
export function caret(modal, width, maxRows) {
|
|
23
|
+
return modal.kind === "type"
|
|
24
|
+
? field.caret(modal.field, width)
|
|
25
|
+
: picker.caret(modal.picker, width, maxRows);
|
|
24
26
|
}
|
package/dist/tui/picker.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { row } from "../ui/render.js";
|
|
3
3
|
import { elide } from "../ui/width.js";
|
|
4
4
|
import { menuWindow, renderMenuRows } from "./components/menu.js";
|
|
5
|
+
import { promptCursor, promptLine } from "./components/prompt.js";
|
|
5
6
|
const WINDOW = 6;
|
|
6
7
|
export function move(picker, step) {
|
|
7
8
|
const shown = matches(picker);
|
|
@@ -44,41 +45,64 @@ export function byKey(picker, text) {
|
|
|
44
45
|
const found = picker.options.findIndex((option) => option.key === typed);
|
|
45
46
|
return found === -1 ? undefined : found;
|
|
46
47
|
}
|
|
47
|
-
export function panel(picker, width, pal, maxRows = WINDOW +
|
|
48
|
+
export function panel(picker, width, pal, maxRows = WINDOW + 3) {
|
|
49
|
+
return layout(picker, width, pal, maxRows).rows;
|
|
50
|
+
}
|
|
51
|
+
/** Caret for the shared query row, relative to the unframed picker body. */
|
|
52
|
+
export function caret(picker, width, maxRows = WINDOW + 3) {
|
|
53
|
+
return layout(picker, width, undefined, maxRows).cursor;
|
|
54
|
+
}
|
|
55
|
+
function layout(picker, width, pal, maxRows) {
|
|
48
56
|
const found = matches(picker);
|
|
49
|
-
const
|
|
57
|
+
const note = picker.description ?? picker.footer;
|
|
58
|
+
const fixed = 1 + (note === undefined ? 0 : 1) + (picker.searchable === true ? 1 : 0);
|
|
50
59
|
const optionRoom = Math.max(1, Math.min(WINDOW, maxRows - fixed));
|
|
51
60
|
const selectedAt = Math.max(0, found.findIndex((entry) => entry.index === picker.index));
|
|
52
61
|
const { first, last } = menuWindow(found.length, selectedAt, optionRoom);
|
|
53
62
|
const shown = found.slice(first, last);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
const colors = pal;
|
|
64
|
+
const options = colors === undefined
|
|
65
|
+
? []
|
|
66
|
+
: shown.length === 0
|
|
67
|
+
? [row(width, [{ text: " no matches", fg: colors.ink.muted }])]
|
|
68
|
+
: renderMenuRows(shown.map(({ option, index }) => ({
|
|
69
|
+
label: option.label,
|
|
70
|
+
hint: option.hint,
|
|
71
|
+
selected: index === picker.index,
|
|
72
|
+
})), width, colors);
|
|
61
73
|
const progress = found.length > shown.length || picker.searchable === true
|
|
62
74
|
? `${found.length === 0 ? "0" : `${first + 1}–${first + shown.length}`} / ${found.length}` +
|
|
63
|
-
(found.length === picker.options.length ? "" : ` · ${picker.options.length} total`)
|
|
75
|
+
(found.length === picker.options.length ? "" : ` · ${picker.options.length} total`)
|
|
64
76
|
: "";
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
const titleRight = picker.right ?? (picker.searchable === true ? undefined : progress);
|
|
78
|
+
const visibleTitleRight = titleRight === undefined
|
|
79
|
+
? undefined
|
|
80
|
+
: elide(titleRight, Math.max(1, Math.floor(width * 0.55)));
|
|
81
|
+
const queryRow = picker.searchable === true && colors !== undefined
|
|
82
|
+
? promptLine(picker.query ?? "", (picker.query ?? "").length, width, colors, {
|
|
83
|
+
placeholder: "type to filter",
|
|
84
|
+
right: progress,
|
|
85
|
+
})
|
|
86
|
+
: undefined;
|
|
87
|
+
const queryCursor = picker.searchable === true
|
|
88
|
+
? promptCursor(picker.query ?? "", (picker.query ?? "").length, width, { right: progress })
|
|
89
|
+
: undefined;
|
|
90
|
+
const queryOffset = 1 + (note === undefined ? 0 : 1);
|
|
91
|
+
return {
|
|
92
|
+
rows: colors === undefined
|
|
69
93
|
? []
|
|
70
|
-
: [
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
]
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
: [
|
|
95
|
+
row(width, picker.title, visibleTitleRight === undefined || visibleTitleRight === ""
|
|
96
|
+
? []
|
|
97
|
+
: [{ text: visibleTitleRight, fg: colors.ink.muted }]),
|
|
98
|
+
...(note === undefined ? [] : [row(width, [{ text: note, fg: colors.ink.muted }])]),
|
|
99
|
+
...(queryRow === undefined ? [] : [queryRow.row]),
|
|
100
|
+
...options,
|
|
101
|
+
],
|
|
102
|
+
cursor: picker.searchable === true
|
|
103
|
+
? { row: queryOffset, col: queryCursor?.col ?? 0 }
|
|
104
|
+
: undefined,
|
|
105
|
+
};
|
|
82
106
|
}
|
|
83
107
|
export function heading(label, about, pal) {
|
|
84
108
|
return [
|
|
@@ -17,8 +17,8 @@ export function transcriptRenderer(draw = render) {
|
|
|
17
17
|
let positions = new WeakMap();
|
|
18
18
|
const dirty = new Set();
|
|
19
19
|
return {
|
|
20
|
-
viewport(blocks, width, height, scroll, palette) {
|
|
21
|
-
sync(blocks, width, palette);
|
|
20
|
+
viewport(blocks, width, height, scroll, palette, state = {}) {
|
|
21
|
+
sync(blocks, width, palette, state);
|
|
22
22
|
const visibleHeight = Math.max(0, height);
|
|
23
23
|
const totalRows = ends.at(-1) ?? 0;
|
|
24
24
|
const maxScroll = Math.max(0, totalRows - visibleHeight);
|
|
@@ -50,19 +50,19 @@ export function transcriptRenderer(draw = render) {
|
|
|
50
50
|
dirty.clear();
|
|
51
51
|
},
|
|
52
52
|
};
|
|
53
|
-
function sync(blocks, width, palette) {
|
|
53
|
+
function sync(blocks, width, palette, state) {
|
|
54
54
|
if (source !== blocks ||
|
|
55
55
|
layoutWidth !== width ||
|
|
56
56
|
layoutPalette !== palette ||
|
|
57
57
|
blocks.length < entries.length ||
|
|
58
58
|
edgeChanged(blocks)) {
|
|
59
|
-
rebuild(blocks, width, palette);
|
|
59
|
+
rebuild(blocks, width, palette, state);
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
|
-
append(blocks, width, palette);
|
|
63
|
-
refreshDirty(width, palette);
|
|
62
|
+
append(blocks, width, palette, state);
|
|
63
|
+
refreshDirty(width, palette, state);
|
|
64
64
|
}
|
|
65
|
-
function rebuild(blocks, width, palette) {
|
|
65
|
+
function rebuild(blocks, width, palette, state) {
|
|
66
66
|
source = blocks;
|
|
67
67
|
layoutWidth = width;
|
|
68
68
|
layoutPalette = palette;
|
|
@@ -70,15 +70,15 @@ export function transcriptRenderer(draw = render) {
|
|
|
70
70
|
ends = [];
|
|
71
71
|
positions = new WeakMap();
|
|
72
72
|
dirty.clear();
|
|
73
|
-
append(blocks, width, palette);
|
|
73
|
+
append(blocks, width, palette, state);
|
|
74
74
|
}
|
|
75
|
-
function append(blocks, width, palette) {
|
|
75
|
+
function append(blocks, width, palette, state) {
|
|
76
76
|
let total = ends.at(-1) ?? 0;
|
|
77
77
|
for (let index = entries.length; index < blocks.length; index++) {
|
|
78
78
|
const block = blocks[index];
|
|
79
79
|
if (block === undefined)
|
|
80
80
|
continue;
|
|
81
|
-
const rows = draw(block, width, palette);
|
|
81
|
+
const rows = draw(block, width, palette, { ...state, previous: blocks[index - 1] });
|
|
82
82
|
entries.push({ block, rows });
|
|
83
83
|
positions.set(block, index);
|
|
84
84
|
dirty.delete(block);
|
|
@@ -86,7 +86,7 @@ export function transcriptRenderer(draw = render) {
|
|
|
86
86
|
ends.push(total);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
function refreshDirty(width, palette) {
|
|
89
|
+
function refreshDirty(width, palette, state) {
|
|
90
90
|
if (dirty.size === 0)
|
|
91
91
|
return;
|
|
92
92
|
const changed = [...dirty]
|
|
@@ -99,8 +99,12 @@ export function transcriptRenderer(draw = render) {
|
|
|
99
99
|
const first = changed[0];
|
|
100
100
|
for (const index of changed) {
|
|
101
101
|
const entry = entries[index];
|
|
102
|
-
if (entry !== undefined)
|
|
103
|
-
entry.rows = draw(entry.block, width, palette
|
|
102
|
+
if (entry !== undefined) {
|
|
103
|
+
entry.rows = draw(entry.block, width, palette, {
|
|
104
|
+
...state,
|
|
105
|
+
previous: entries[index - 1]?.block,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
104
108
|
}
|
|
105
109
|
recomputeEnds(first);
|
|
106
110
|
}
|
package/dist/tui/turn.js
CHANGED
|
@@ -5,15 +5,9 @@
|
|
|
5
5
|
// neither has to know how the other is built.
|
|
6
6
|
import { condense, diff } from "../ui/diff.js";
|
|
7
7
|
import { promptFor } from "./approve.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* Every one of these is read off an event that has actually arrived, never
|
|
12
|
-
* from a guess about where the turn probably is. A status line that says one
|
|
13
|
-
* thing for the whole turn is decoration; this one is the only window into a
|
|
14
|
-
* process that is otherwise silent for seconds at a time, so it has to be
|
|
15
|
-
* worth believing.
|
|
16
|
-
*/
|
|
8
|
+
// Semantic activity labels remain useful state even though the quiet footer
|
|
9
|
+
// reduces them to one stable interruption hint. Reasoning and tools identify
|
|
10
|
+
// the live work in the transcript itself.
|
|
17
11
|
const WAITING = "Waiting";
|
|
18
12
|
const THINKING = "Thinking";
|
|
19
13
|
const WRITING = "Writing";
|
|
@@ -38,10 +32,18 @@ export function transcribe(stage) {
|
|
|
38
32
|
return changed ? block : undefined;
|
|
39
33
|
};
|
|
40
34
|
return {
|
|
41
|
-
finish() {
|
|
35
|
+
finish(reason) {
|
|
42
36
|
const changed = close();
|
|
43
37
|
if (changed !== undefined)
|
|
44
38
|
stage.render(changed);
|
|
39
|
+
for (const block of tools.values()) {
|
|
40
|
+
if (block.kind !== "tool" || block.tone !== "pending")
|
|
41
|
+
continue;
|
|
42
|
+
block.tone = "fail";
|
|
43
|
+
block.right = reason ?? "failed";
|
|
44
|
+
block.startedAt = undefined;
|
|
45
|
+
stage.render(block);
|
|
46
|
+
}
|
|
45
47
|
},
|
|
46
48
|
onStep(current, total) {
|
|
47
49
|
step = current;
|
|
@@ -87,21 +89,34 @@ export function transcribe(stage) {
|
|
|
87
89
|
kind: "tool",
|
|
88
90
|
name: call.name,
|
|
89
91
|
target: target(call.input),
|
|
90
|
-
right: "
|
|
92
|
+
right: "running",
|
|
91
93
|
tone: "pending",
|
|
92
94
|
body: preview(call, look),
|
|
95
|
+
startedAt: Date.now(),
|
|
93
96
|
};
|
|
94
97
|
tools.set(call.id, block);
|
|
95
98
|
stage.emit(block);
|
|
96
99
|
stage.render(block);
|
|
97
100
|
},
|
|
101
|
+
onToolOutput(call, output) {
|
|
102
|
+
const block = tools.get(call.id);
|
|
103
|
+
if (block === undefined || block.kind !== "tool" || block.tone !== "pending")
|
|
104
|
+
return;
|
|
105
|
+
block.body = details(output, "out");
|
|
106
|
+
stage.render(block);
|
|
107
|
+
},
|
|
98
108
|
onToolResult(call, result, summary) {
|
|
99
109
|
stage.status(waiting(step, steps));
|
|
100
110
|
const block = tools.get(call.id);
|
|
101
111
|
if (block === undefined || block.kind !== "tool")
|
|
102
112
|
return;
|
|
113
|
+
if (block.tone === "deny") {
|
|
114
|
+
stage.render(block);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
103
117
|
block.tone = result.isError ? "fail" : "ok";
|
|
104
118
|
block.right = summary ?? "";
|
|
119
|
+
block.startedAt = undefined;
|
|
105
120
|
// A failure replaces the preview: what the call was going to do stops
|
|
106
121
|
// being the interesting part the moment it did not do it.
|
|
107
122
|
const outcome = result.isError
|
|
@@ -112,31 +127,56 @@ export function transcribe(stage) {
|
|
|
112
127
|
stage.render(block);
|
|
113
128
|
},
|
|
114
129
|
approve(call) {
|
|
115
|
-
|
|
130
|
+
const block = tools.get(call.id);
|
|
131
|
+
if (stage.approved(call)) {
|
|
132
|
+
if (block !== undefined && block.kind === "tool") {
|
|
133
|
+
block.right = "running";
|
|
134
|
+
block.startedAt ??= Date.now();
|
|
135
|
+
stage.render(block);
|
|
136
|
+
}
|
|
116
137
|
return Promise.resolve(true);
|
|
138
|
+
}
|
|
117
139
|
const changed = close();
|
|
118
140
|
if (changed !== undefined)
|
|
119
141
|
stage.render(changed);
|
|
120
142
|
stage.status(ASKING);
|
|
143
|
+
if (block !== undefined && block.kind === "tool") {
|
|
144
|
+
block.right = pendingApproval(block.body);
|
|
145
|
+
block.startedAt = undefined;
|
|
146
|
+
stage.render(block);
|
|
147
|
+
}
|
|
121
148
|
return new Promise((resolve) => {
|
|
122
149
|
stage.ask(promptFor(call, target(call.input), stage.palette), (answer) => {
|
|
123
150
|
if (answer === "always")
|
|
124
151
|
stage.remember(call);
|
|
125
152
|
const approved = answer !== "no";
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
153
|
+
const settled = tools.get(call.id);
|
|
154
|
+
if (settled !== undefined && settled.kind === "tool") {
|
|
155
|
+
if (approved) {
|
|
156
|
+
settled.tone = "pending";
|
|
157
|
+
settled.right = "running";
|
|
158
|
+
settled.startedAt = Date.now();
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
settled.tone = "deny";
|
|
162
|
+
settled.right = "denied";
|
|
163
|
+
settled.startedAt = undefined;
|
|
164
|
+
}
|
|
131
165
|
}
|
|
132
166
|
stage.status(approved ? `Running ${call.name}` : waiting(step, steps));
|
|
133
|
-
stage.render(
|
|
167
|
+
stage.render(settled);
|
|
134
168
|
resolve(approved);
|
|
135
169
|
});
|
|
136
170
|
});
|
|
137
171
|
},
|
|
138
172
|
};
|
|
139
173
|
}
|
|
174
|
+
function pendingApproval(body) {
|
|
175
|
+
const added = body?.filter((detail) => detail.kind === "add").length ?? 0;
|
|
176
|
+
const removed = body?.filter((detail) => detail.kind === "del").length ?? 0;
|
|
177
|
+
const summary = added + removed === 0 ? "" : `+${added} −${removed} · `;
|
|
178
|
+
return `${summary}pending approval`;
|
|
179
|
+
}
|
|
140
180
|
function waiting(step, total) {
|
|
141
181
|
return step === 1 ? WAITING : `${WAITING} · step ${step}/${total}`;
|
|
142
182
|
}
|
package/dist/tui/view.js
CHANGED
|
@@ -6,9 +6,8 @@ import { renderCommandMenu } from "./components/command-menu.js";
|
|
|
6
6
|
import { renderComposer } from "./components/composer.js";
|
|
7
7
|
import { renderDock } from "./components/dock.js";
|
|
8
8
|
import { renderFooter } from "./components/footer.js";
|
|
9
|
-
import { renderStatus
|
|
9
|
+
import { renderStatus } from "./components/status.js";
|
|
10
10
|
import { transcriptRenderer } from "./transcript-view.js";
|
|
11
|
-
export { spinner } from "./components/status.js";
|
|
12
11
|
const DOCK_MAX = 6;
|
|
13
12
|
const FOOTER_ROWS = 1;
|
|
14
13
|
const DOCK_RULES = 2;
|
|
@@ -26,7 +25,7 @@ export function compose(view, size, transcript = transcriptRenderer()) {
|
|
|
26
25
|
// Any spare height belongs above the conversation, so short sessions grow
|
|
27
26
|
// upward from the fixed dock rhythm instead of leaving a changing hole
|
|
28
27
|
// beneath the latest reply.
|
|
29
|
-
const viewport = transcript.viewport(view.blocks, width, transcriptHeight, view.scroll, view.pal);
|
|
28
|
+
const viewport = transcript.viewport(view.blocks, width, transcriptHeight, view.scroll, view.pal, { spin: view.spin, reducedMotion: view.reducedMotion, now: view.now });
|
|
30
29
|
const cursor = dock.cursor === undefined
|
|
31
30
|
? undefined
|
|
32
31
|
: { row: transcriptHeight + dock.cursor.row, col: dock.cursor.col };
|
|
@@ -40,15 +39,14 @@ function dockRows(view, width, height) {
|
|
|
40
39
|
status: view.modal === undefined ? view.status : undefined,
|
|
41
40
|
feedback: view.feedback,
|
|
42
41
|
readiness: view.readiness,
|
|
43
|
-
tick: view.spin,
|
|
44
|
-
reducedMotion: view.reducedMotion === true,
|
|
45
42
|
unseen: view.unseen ?? 0,
|
|
46
43
|
}, view.pal);
|
|
47
44
|
const footer = renderFooter(view.footer, status, width, view.pal);
|
|
48
45
|
if (view.modal !== undefined) {
|
|
46
|
+
const maxModalRows = height - FOOTER_ROWS - TRANSCRIPT_MIN - TRANSCRIPT_DOCK_GAP - DOCK_RULES;
|
|
49
47
|
const body = {
|
|
50
|
-
rows: modal.panel(view.modal, width, view.pal,
|
|
51
|
-
cursor: modal.caret(view.modal, width),
|
|
48
|
+
rows: modal.panel(view.modal, width, view.pal, maxModalRows),
|
|
49
|
+
cursor: modal.caret(view.modal, width, maxModalRows),
|
|
52
50
|
};
|
|
53
51
|
const dock = renderDock(body, width, view.pal, true);
|
|
54
52
|
return {
|
|
@@ -60,11 +58,11 @@ function dockRows(view, width, height) {
|
|
|
60
58
|
const available = height
|
|
61
59
|
- TRANSCRIPT_MIN
|
|
62
60
|
- TRANSCRIPT_DOCK_GAP
|
|
63
|
-
- menu.length
|
|
61
|
+
- menu.rows.length
|
|
64
62
|
- FOOTER_ROWS
|
|
65
63
|
- DOCK_RULES;
|
|
66
|
-
const composer = renderComposer(view.editor, width, Math.max(1, Math.min(DOCK_MAX, available)), view.pal);
|
|
67
|
-
const dock = renderDock({ rows: [...composer.rows, ...menu], cursor: composer.cursor }, width, view.pal, menu.length > 0);
|
|
64
|
+
const composer = renderComposer(view.editor, width, Math.max(1, Math.min(DOCK_MAX, available)), view.pal, menu.right);
|
|
65
|
+
const dock = renderDock({ rows: [...composer.rows, ...menu.rows], cursor: composer.cursor }, width, view.pal, menu.rows.length > 0);
|
|
68
66
|
return {
|
|
69
67
|
rows: ["", ...dock.rows, ...footer],
|
|
70
68
|
cursor: offset(dock.cursor, TRANSCRIPT_DOCK_GAP),
|
package/dist/ui/render.js
CHANGED
|
@@ -20,6 +20,10 @@ let colorEnabled = terminalColor;
|
|
|
20
20
|
export function configureColor(enabled) {
|
|
21
21
|
colorEnabled = terminalColor && enabled;
|
|
22
22
|
}
|
|
23
|
+
/** Whether semantic colour and surfaces will be visible on this terminal. */
|
|
24
|
+
export function hasColor() {
|
|
25
|
+
return colorEnabled;
|
|
26
|
+
}
|
|
23
27
|
export function paint(seg) {
|
|
24
28
|
const text = terminalText(seg.text);
|
|
25
29
|
if (!colorEnabled)
|
|
@@ -85,13 +89,17 @@ export function fitSegs(segs, cols) {
|
|
|
85
89
|
*/
|
|
86
90
|
export function row(width, left, right = [], ground, padX = PAD) {
|
|
87
91
|
const inner = Math.max(0, width - padX * 2);
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
// The right column normally wins over the left, but it must still obey the
|
|
93
|
+
// row boundary when it cannot fit by itself (long commands and paths do
|
|
94
|
+
// reach approval titles on narrow terminals).
|
|
95
|
+
const fittedRight = fitSegs(right, inner);
|
|
96
|
+
const rightW = plainLen(fittedRight);
|
|
97
|
+
const gutter = fittedRight.length > 0 && rightW < inner ? 1 : 0;
|
|
90
98
|
const fitted = fitSegs(left, Math.max(0, inner - rightW - gutter));
|
|
91
99
|
// An empty pad is no pad at all: a zero-width segment still costs a pair of
|
|
92
100
|
// escape sequences on every row of every frame.
|
|
93
101
|
const lead = padX === 0 ? [] : [{ text: " ".repeat(padX), bg: ground }];
|
|
94
|
-
if (ground === undefined &&
|
|
102
|
+
if (ground === undefined && fittedRight.length === 0) {
|
|
95
103
|
// Nothing to draw is an empty row, not an indent: leading spaces are
|
|
96
104
|
// invisible on screen and very visible in a paste buffer.
|
|
97
105
|
if (plainLen(fitted) === 0)
|
|
@@ -103,7 +111,7 @@ export function row(width, left, right = [], ground, padX = PAD) {
|
|
|
103
111
|
...lead,
|
|
104
112
|
...fitted.map((seg) => ({ ...seg, bg: seg.bg ?? ground })),
|
|
105
113
|
{ text: " ".repeat(gap), bg: ground },
|
|
106
|
-
...
|
|
114
|
+
...fittedRight.map((seg) => ({ ...seg, bg: seg.bg ?? ground })),
|
|
107
115
|
...lead,
|
|
108
116
|
]
|
|
109
117
|
.map(paint)
|