@giovannijecha/jecode 0.1.5 → 0.1.6-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -7
- package/bin/jecode.js +11 -1
- 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.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 +49 -17
- package/dist/tui/view.js +8 -10
- package/dist/ui/render.js +12 -4
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -53,6 +53,10 @@ jecode --version
|
|
|
53
53
|
To try prereleases instead, install the opt-in **next** channel with
|
|
54
54
|
`npm install --global @giovannijecha/jecode@next`.
|
|
55
55
|
|
|
56
|
+
Published npm packages are the supported installation artifacts. Git URL
|
|
57
|
+
installs are intentionally unsupported: the source tree contains no generated
|
|
58
|
+
runtime and defines no install-time build hook.
|
|
59
|
+
|
|
56
60
|
Then open the project you want to work on and run Jecode:
|
|
57
61
|
|
|
58
62
|
~~~console
|
|
@@ -101,7 +105,7 @@ report 24 or newer.
|
|
|
101
105
|
~~~console
|
|
102
106
|
git clone https://github.com/giovannijecha/jecode.git
|
|
103
107
|
cd jecode
|
|
104
|
-
npm ci
|
|
108
|
+
npm ci --ignore-scripts
|
|
105
109
|
npm run build:release
|
|
106
110
|
npm link
|
|
107
111
|
jecode
|
|
@@ -109,7 +113,8 @@ jecode
|
|
|
109
113
|
|
|
110
114
|
Development runs TypeScript directly with **npm run start**. **dist/** is an
|
|
111
115
|
ignored, generated tree used only by linked commands and release tarballs.
|
|
112
|
-
**npm pack**
|
|
116
|
+
**npm run pack:release** rebuilds it from a clean target before packing; the
|
|
117
|
+
trusted publish workflow performs the same explicit build. Installing the
|
|
113
118
|
published package runs no compilation or installation scripts.
|
|
114
119
|
|
|
115
120
|
## First session
|
|
@@ -159,8 +164,9 @@ Useful controls:
|
|
|
159
164
|
the place you are reading.
|
|
160
165
|
- **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
|
|
161
166
|
|
|
162
|
-
The one-line footer keeps model, effort, and workspace on the left. Live work
|
|
163
|
-
|
|
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
|
|
164
170
|
the transcript.
|
|
165
171
|
|
|
166
172
|
## Configuration
|
|
@@ -244,12 +250,13 @@ expectations.
|
|
|
244
250
|
## Development
|
|
245
251
|
|
|
246
252
|
~~~console
|
|
247
|
-
npm ci
|
|
253
|
+
npm ci --ignore-scripts
|
|
248
254
|
npm run check
|
|
249
255
|
~~~
|
|
250
256
|
|
|
251
|
-
The visual lab exercises the
|
|
252
|
-
|
|
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:
|
|
253
260
|
|
|
254
261
|
~~~console
|
|
255
262
|
npm run tui:lab
|
package/bin/jecode.js
CHANGED
|
@@ -7,5 +7,15 @@ if (major < 24) {
|
|
|
7
7
|
);
|
|
8
8
|
process.exitCode = 1;
|
|
9
9
|
} else {
|
|
10
|
-
|
|
10
|
+
const runtime = new URL("../dist/main.js", import.meta.url);
|
|
11
|
+
try {
|
|
12
|
+
await import(runtime.href);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error?.code !== "ERR_MODULE_NOT_FOUND" || error.url !== runtime.href) throw error;
|
|
15
|
+
process.stderr.write(
|
|
16
|
+
"jecode's compiled runtime is missing; install @giovannijecha/jecode from npm " +
|
|
17
|
+
"or run npm run build:release in a source checkout\n",
|
|
18
|
+
);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
11
21
|
}
|
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
|
}
|
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";
|
|
@@ -87,21 +81,34 @@ export function transcribe(stage) {
|
|
|
87
81
|
kind: "tool",
|
|
88
82
|
name: call.name,
|
|
89
83
|
target: target(call.input),
|
|
90
|
-
right: "
|
|
84
|
+
right: "running",
|
|
91
85
|
tone: "pending",
|
|
92
86
|
body: preview(call, look),
|
|
87
|
+
startedAt: Date.now(),
|
|
93
88
|
};
|
|
94
89
|
tools.set(call.id, block);
|
|
95
90
|
stage.emit(block);
|
|
96
91
|
stage.render(block);
|
|
97
92
|
},
|
|
93
|
+
onToolOutput(call, output) {
|
|
94
|
+
const block = tools.get(call.id);
|
|
95
|
+
if (block === undefined || block.kind !== "tool" || block.tone !== "pending")
|
|
96
|
+
return;
|
|
97
|
+
block.body = details(output, "out");
|
|
98
|
+
stage.render(block);
|
|
99
|
+
},
|
|
98
100
|
onToolResult(call, result, summary) {
|
|
99
101
|
stage.status(waiting(step, steps));
|
|
100
102
|
const block = tools.get(call.id);
|
|
101
103
|
if (block === undefined || block.kind !== "tool")
|
|
102
104
|
return;
|
|
105
|
+
if (block.tone === "deny") {
|
|
106
|
+
stage.render(block);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
103
109
|
block.tone = result.isError ? "fail" : "ok";
|
|
104
110
|
block.right = summary ?? "";
|
|
111
|
+
block.startedAt = undefined;
|
|
105
112
|
// A failure replaces the preview: what the call was going to do stops
|
|
106
113
|
// being the interesting part the moment it did not do it.
|
|
107
114
|
const outcome = result.isError
|
|
@@ -112,31 +119,56 @@ export function transcribe(stage) {
|
|
|
112
119
|
stage.render(block);
|
|
113
120
|
},
|
|
114
121
|
approve(call) {
|
|
115
|
-
|
|
122
|
+
const block = tools.get(call.id);
|
|
123
|
+
if (stage.approved(call)) {
|
|
124
|
+
if (block !== undefined && block.kind === "tool") {
|
|
125
|
+
block.right = "running";
|
|
126
|
+
block.startedAt ??= Date.now();
|
|
127
|
+
stage.render(block);
|
|
128
|
+
}
|
|
116
129
|
return Promise.resolve(true);
|
|
130
|
+
}
|
|
117
131
|
const changed = close();
|
|
118
132
|
if (changed !== undefined)
|
|
119
133
|
stage.render(changed);
|
|
120
134
|
stage.status(ASKING);
|
|
135
|
+
if (block !== undefined && block.kind === "tool") {
|
|
136
|
+
block.right = pendingApproval(block.body);
|
|
137
|
+
block.startedAt = undefined;
|
|
138
|
+
stage.render(block);
|
|
139
|
+
}
|
|
121
140
|
return new Promise((resolve) => {
|
|
122
141
|
stage.ask(promptFor(call, target(call.input), stage.palette), (answer) => {
|
|
123
142
|
if (answer === "always")
|
|
124
143
|
stage.remember(call);
|
|
125
144
|
const approved = answer !== "no";
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
const settled = tools.get(call.id);
|
|
146
|
+
if (settled !== undefined && settled.kind === "tool") {
|
|
147
|
+
if (approved) {
|
|
148
|
+
settled.tone = "pending";
|
|
149
|
+
settled.right = "running";
|
|
150
|
+
settled.startedAt = Date.now();
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
settled.tone = "deny";
|
|
154
|
+
settled.right = "denied";
|
|
155
|
+
settled.startedAt = undefined;
|
|
156
|
+
}
|
|
131
157
|
}
|
|
132
158
|
stage.status(approved ? `Running ${call.name}` : waiting(step, steps));
|
|
133
|
-
stage.render(
|
|
159
|
+
stage.render(settled);
|
|
134
160
|
resolve(approved);
|
|
135
161
|
});
|
|
136
162
|
});
|
|
137
163
|
},
|
|
138
164
|
};
|
|
139
165
|
}
|
|
166
|
+
function pendingApproval(body) {
|
|
167
|
+
const added = body?.filter((detail) => detail.kind === "add").length ?? 0;
|
|
168
|
+
const removed = body?.filter((detail) => detail.kind === "del").length ?? 0;
|
|
169
|
+
const summary = added + removed === 0 ? "" : `+${added} −${removed} · `;
|
|
170
|
+
return `${summary}pending approval`;
|
|
171
|
+
}
|
|
140
172
|
function waiting(step, total) {
|
|
141
173
|
return step === 1 ? WAITING : `${WAITING} · step ${step}/${total}`;
|
|
142
174
|
}
|
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 ? 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)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giovannijecha/jecode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6-rc.1",
|
|
4
4
|
"description": "An owned coding agent with zero external runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,18 +36,19 @@
|
|
|
36
36
|
"registry": "https://registry.npmjs.org/"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"prepack": "node dev/build-release.ts --quiet",
|
|
40
39
|
"build:release": "node dev/build-release.ts",
|
|
40
|
+
"pack:release": "npm run build:release && npm pack --ignore-scripts",
|
|
41
41
|
"start": "node src/main.ts",
|
|
42
42
|
"tui:lab": "node dev/tui-lab.ts",
|
|
43
43
|
"bench:transcript": "node dev/benchmark-transcript.ts",
|
|
44
44
|
"typecheck": "tsc --noEmit",
|
|
45
45
|
"test": "npm run build:release && node --test",
|
|
46
46
|
"coverage": "npm run build:release && node --test --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-lines=80 --test-coverage-branches=75 --test-coverage-functions=75",
|
|
47
|
+
"check:source-tree": "node dev/check-source-tree.ts",
|
|
47
48
|
"check:release-tag": "node dev/check-release-tag.ts",
|
|
48
|
-
"check:package": "node dev/check-package.ts",
|
|
49
|
-
"check:install": "node dev/check-installed-cli.ts",
|
|
50
|
-
"check": "npm run typecheck && npm run coverage && npm run check:package && npm run check:install"
|
|
49
|
+
"check:package": "npm run build:release -- --quiet && node dev/check-package.ts",
|
|
50
|
+
"check:install": "npm run build:release -- --quiet && node dev/check-installed-cli.ts",
|
|
51
|
+
"check": "npm run check:source-tree && npm run typecheck && npm run coverage && npm run check:package && npm run check:install"
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {},
|
|
53
54
|
"devDependencies": {
|