@giovannijecha/jecode 0.1.8 → 0.1.9
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 +9 -12
- package/dist/commands.js +26 -101
- package/dist/credential-commands.js +8 -10
- package/dist/permission-command.js +107 -0
- package/dist/permissions.js +113 -0
- package/dist/provider-commands.js +4 -32
- package/dist/settings-command.js +2 -3
- package/dist/tools/shell.js +1 -1
- package/dist/transcript.js +0 -3
- package/dist/tui/app-workflows.js +12 -17
- package/dist/tui/app.js +6 -11
- package/dist/tui/approve.js +6 -34
- package/dist/tui/blocks.js +1 -3
- package/dist/tui/complete.js +4 -4
- package/dist/tui/components/menu.js +4 -3
- package/dist/tui/components/misc.js +0 -6
- package/dist/tui/components/status.js +6 -7
- package/dist/tui/feedback.js +1 -3
- package/dist/tui/help.js +29 -0
- package/dist/tui/modal.js +22 -11
- package/dist/tui/overlay.js +15 -4
- package/dist/tui/picker.js +1 -1
- package/dist/tui/session-view.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<
|
|
7
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/brand/wordmark-light.svg">
|
|
8
|
-
<source media="(prefers-color-scheme: light)" srcset="docs/assets/brand/wordmark-dark.svg">
|
|
9
|
-
<img src="docs/assets/brand/wordmark-dark.svg" width="280" alt="Jecode">
|
|
10
|
-
</picture>
|
|
6
|
+
<img src="docs/assets/brand/wordmark-steel.svg" width="280" alt="Jecode">
|
|
11
7
|
</p>
|
|
12
8
|
|
|
13
9
|
<p align="center"><strong>Your code. Your loop.</strong></p>
|
|
@@ -175,11 +171,10 @@ Type **/** to open searchable command completion inside the composer.
|
|
|
175
171
|
| /providers | Switch the provider for the next turn |
|
|
176
172
|
| /models | Search the live model catalogue |
|
|
177
173
|
| /credentials | Add, replace, inspect, or forget saved credentials |
|
|
178
|
-
| /permissions |
|
|
179
|
-
| /
|
|
180
|
-
| /new | Start a clean in-memory conversation |
|
|
174
|
+
| /permissions | Manage session tool access and remembered approvals |
|
|
175
|
+
| /new | Start a clean conversation and reset session tool permissions |
|
|
181
176
|
| /export | Save a timestamped Markdown transcript in the launch directory |
|
|
182
|
-
| /help |
|
|
177
|
+
| /help | Open a temporary keyboard reference in the composer dock |
|
|
183
178
|
| /exit | Restore the terminal and exit |
|
|
184
179
|
|
|
185
180
|
Useful controls:
|
|
@@ -196,7 +191,9 @@ Useful controls:
|
|
|
196
191
|
The one-line footer keeps model, effort, and workspace on the left. Live work
|
|
197
192
|
stays visible on the reasoning and tool rail; the right edge carries the
|
|
198
193
|
interrupt hint, readiness guidance, and temporary feedback without polluting
|
|
199
|
-
the transcript.
|
|
194
|
+
the transcript. Slash commands never append content to the conversation or its
|
|
195
|
+
Markdown export; **/help** closes with **Esc**, and token accounting remains
|
|
196
|
+
internal to the active session.
|
|
200
197
|
|
|
201
198
|
## Configuration
|
|
202
199
|
|
|
@@ -241,8 +238,8 @@ untrusted data.
|
|
|
241
238
|
|
|
242
239
|
- Tool paths are confined to the selected workspace. Writes reject symlink and
|
|
243
240
|
junction components, then revalidate the boundary during atomic replacement.
|
|
244
|
-
- Dangerous tools
|
|
245
|
-
**--auto-approve**.
|
|
241
|
+
- Dangerous tools ask by default unless explicitly allowed for the session in
|
|
242
|
+
**/permissions** or the process started with **--auto-approve**.
|
|
246
243
|
- Credential fields are masked and excluded from transcripts. Approved shell
|
|
247
244
|
commands receive no credential-like environment variables, and recognized
|
|
248
245
|
credential values are redacted before tool output reaches the model, screen,
|
package/dist/commands.js
CHANGED
|
@@ -4,33 +4,27 @@
|
|
|
4
4
|
// without asking the provider what it has — but none of them ever sends a
|
|
5
5
|
// message. Provider and credential interaction lives in provider-commands.ts;
|
|
6
6
|
// this file keeps command discovery, dispatch, and local session operations.
|
|
7
|
-
import {
|
|
8
|
-
import { modelsCommand, providersCommand, setupCommand } from "./provider-commands.js";
|
|
7
|
+
import { modelsCommand, providersCommand } from "./provider-commands.js";
|
|
9
8
|
import { credentialsCommand } from "./credential-commands.js";
|
|
9
|
+
import { permissionsCommand } from "./permission-command.js";
|
|
10
10
|
import { effortCommand, settingsCommand } from "./settings-command.js";
|
|
11
|
-
import { emptyUsage
|
|
11
|
+
import { emptyUsage } from "./usage.js";
|
|
12
12
|
/**
|
|
13
13
|
* The commands, declared once.
|
|
14
14
|
*
|
|
15
|
-
* `/help` and the completion menu both read this list, so a command cannot
|
|
16
|
-
* exist in the switch below and be missing from the two places that tell the
|
|
17
|
-
* user it exists.
|
|
18
|
-
*
|
|
19
15
|
* Deliberately short. `/settings` owns persistent defaults; the narrower
|
|
20
|
-
* provider, model, effort,
|
|
21
|
-
*
|
|
16
|
+
* provider, model, effort, and credential commands remain useful direct paths
|
|
17
|
+
* into the same interactions.
|
|
22
18
|
*/
|
|
23
19
|
export const COMMANDS = [
|
|
24
|
-
{ name: "help", blurb: "
|
|
20
|
+
{ name: "help", blurb: "show keyboard controls" },
|
|
25
21
|
{ name: "exit", blurb: "exit and restore the terminal" },
|
|
26
|
-
{ name: "new", blurb: "start
|
|
27
|
-
{ name: "usage", blurb: "show token usage for this session" },
|
|
22
|
+
{ name: "new", blurb: "start clean and reset tool permissions" },
|
|
28
23
|
{ name: "export", blurb: "save this transcript as Markdown" },
|
|
29
|
-
{ name: "permissions", blurb: "
|
|
24
|
+
{ name: "permissions", blurb: "manage session tool access" },
|
|
30
25
|
{ name: "settings", blurb: "change and save jecode defaults" },
|
|
31
26
|
{ name: "effort", blurb: "set the reasoning effort" },
|
|
32
27
|
{ name: "credentials", blurb: "inspect, replace, or forget API keys" },
|
|
33
|
-
{ name: "setup", blurb: "make the current provider ready" },
|
|
34
28
|
{ name: "models", blurb: "pick a model, from what the provider offers" },
|
|
35
29
|
{ name: "providers", blurb: "pick a provider" },
|
|
36
30
|
];
|
|
@@ -38,7 +32,16 @@ export async function handleCommand(line, session, host) {
|
|
|
38
32
|
const [name] = line.slice(1).trim().split(/\s+/);
|
|
39
33
|
switch (name) {
|
|
40
34
|
case "help":
|
|
41
|
-
host.
|
|
35
|
+
if (host.showHelp === undefined) {
|
|
36
|
+
host.emit({
|
|
37
|
+
kind: "notice",
|
|
38
|
+
text: "interactive help needs the TUI · run jecode --help for startup options",
|
|
39
|
+
tone: "info",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
await host.showHelp();
|
|
44
|
+
}
|
|
42
45
|
return "handled";
|
|
43
46
|
case "exit":
|
|
44
47
|
return "exit";
|
|
@@ -46,10 +49,7 @@ export async function handleCommand(line, session, host) {
|
|
|
46
49
|
session.history.length = 0;
|
|
47
50
|
session.usage = emptyUsage();
|
|
48
51
|
host.reset?.();
|
|
49
|
-
host.emit({ kind: "notice", text: "new session
|
|
50
|
-
return "handled";
|
|
51
|
-
case "usage":
|
|
52
|
-
host.emit(usage(session));
|
|
52
|
+
host.emit({ kind: "notice", text: "new session", tone: "info" });
|
|
53
53
|
return "handled";
|
|
54
54
|
case "export":
|
|
55
55
|
if (host.exportTranscript === undefined) {
|
|
@@ -57,11 +57,11 @@ export async function handleCommand(line, session, host) {
|
|
|
57
57
|
}
|
|
58
58
|
else {
|
|
59
59
|
const saved = await host.exportTranscript();
|
|
60
|
-
host.emit({ kind: "notice", text: `
|
|
60
|
+
host.emit({ kind: "notice", text: `saved · ${saved}`, tone: "info" });
|
|
61
61
|
}
|
|
62
62
|
return "handled";
|
|
63
63
|
case "permissions":
|
|
64
|
-
await
|
|
64
|
+
await permissionsCommand(session, host);
|
|
65
65
|
return "handled";
|
|
66
66
|
case "settings":
|
|
67
67
|
await settingsCommand(session, host);
|
|
@@ -72,9 +72,6 @@ export async function handleCommand(line, session, host) {
|
|
|
72
72
|
case "credentials":
|
|
73
73
|
await credentialsCommand(session, host);
|
|
74
74
|
return "handled";
|
|
75
|
-
case "setup":
|
|
76
|
-
await setupCommand(session, host);
|
|
77
|
-
return "handled";
|
|
78
75
|
case "models":
|
|
79
76
|
await modelsCommand(session, host);
|
|
80
77
|
return "handled";
|
|
@@ -82,89 +79,17 @@ export async function handleCommand(line, session, host) {
|
|
|
82
79
|
await providersCommand(session, host);
|
|
83
80
|
return "handled";
|
|
84
81
|
default:
|
|
85
|
-
host.emit({
|
|
82
|
+
host.emit({
|
|
83
|
+
kind: "notice",
|
|
84
|
+
text: `unknown command /${name ?? ""} · type / to browse commands`,
|
|
85
|
+
tone: "warn",
|
|
86
|
+
});
|
|
86
87
|
return "handled";
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
|
-
function usage(session) {
|
|
90
|
-
const value = session.usage;
|
|
91
|
-
const rows = [
|
|
92
|
-
` requests${String(value.requests).padStart(18)}`,
|
|
93
|
-
` latest context${formatTokens(value.lastInputTokens).padStart(12)}`,
|
|
94
|
-
` input${formatTokens(value.inputTokens).padStart(21)}`,
|
|
95
|
-
` output${formatTokens(value.outputTokens).padStart(20)}`,
|
|
96
|
-
];
|
|
97
|
-
if (value.cachedInputTokens > 0)
|
|
98
|
-
rows.push(` cached input${formatTokens(value.cachedInputTokens).padStart(14)}`);
|
|
99
|
-
if (value.cacheWriteInputTokens > 0) {
|
|
100
|
-
rows.push(` cache writes${formatTokens(value.cacheWriteInputTokens).padStart(14)}`);
|
|
101
|
-
}
|
|
102
|
-
if (value.reasoningTokens > 0)
|
|
103
|
-
rows.push(` reasoning${formatTokens(value.reasoningTokens).padStart(17)}`);
|
|
104
|
-
return { kind: "list", items: rows.map((text) => ({ text, dim: false })) };
|
|
105
|
-
}
|
|
106
|
-
async function permissions(session, host) {
|
|
107
|
-
const choose = chooser(host);
|
|
108
|
-
if (choose === undefined)
|
|
109
|
-
return;
|
|
110
|
-
if (session.config.autoApprove) {
|
|
111
|
-
host.emit({
|
|
112
|
-
kind: "notice",
|
|
113
|
-
text: "--auto-approve is active · every dangerous call is allowed for this process",
|
|
114
|
-
tone: "warn",
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
const entries = host.permissions?.() ?? [];
|
|
118
|
-
if (entries.length === 0) {
|
|
119
|
-
host.emit({ kind: "notice", text: "no remembered session permissions", tone: "info" });
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const index = await choose({
|
|
123
|
-
title: heading("revoke permission", "applies only to this session", session.palette),
|
|
124
|
-
options: [
|
|
125
|
-
...entries.map((entry) => ({ label: entry.label, hint: "revoke" })),
|
|
126
|
-
{ label: "all remembered permissions", hint: "revoke all" },
|
|
127
|
-
],
|
|
128
|
-
index: 0,
|
|
129
|
-
});
|
|
130
|
-
if (index === undefined)
|
|
131
|
-
return;
|
|
132
|
-
if (index === entries.length) {
|
|
133
|
-
host.revokePermission?.();
|
|
134
|
-
host.emit({ kind: "notice", text: "all remembered permissions revoked", tone: "info" });
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const entry = entries[index];
|
|
138
|
-
if (entry === undefined)
|
|
139
|
-
return;
|
|
140
|
-
host.revokePermission?.(entry.key);
|
|
141
|
-
host.emit({ kind: "notice", text: `revoked · ${entry.label}`, tone: "info" });
|
|
142
|
-
}
|
|
143
90
|
function chooser(host) {
|
|
144
91
|
if (host.choose === undefined) {
|
|
145
92
|
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
146
93
|
}
|
|
147
94
|
return host.choose;
|
|
148
95
|
}
|
|
149
|
-
function help() {
|
|
150
|
-
const width = Math.max(...COMMANDS.map((c) => label(c).length));
|
|
151
|
-
return {
|
|
152
|
-
kind: "list",
|
|
153
|
-
items: [
|
|
154
|
-
...COMMANDS.map((command) => ({
|
|
155
|
-
text: ` ${label(command).padEnd(width + 4)}${command.blurb}`,
|
|
156
|
-
dim: false,
|
|
157
|
-
})),
|
|
158
|
-
{ text: "", dim: true },
|
|
159
|
-
{ text: " esc interrupts a running turn · ctrl+c exits", dim: true },
|
|
160
|
-
{ text: " wheel or pgup / pgdn scrolls · ctrl+l redraws", dim: true },
|
|
161
|
-
{ text: " ctrl+o expands the latest reasoning or tool details", dim: true },
|
|
162
|
-
{ text: " type in the model picker to filter · home/end jumps", dim: true },
|
|
163
|
-
{ text: " ↑↓ selects an open menu · tab completes · esc closes it", dim: true },
|
|
164
|
-
{ text: " enter selects or sends · alt+enter starts a new line", dim: true },
|
|
165
|
-
],
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
function label(command) {
|
|
169
|
-
return `/${command.name}`;
|
|
170
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Credential command flows shared by
|
|
1
|
+
// Credential command flows shared by settings and provider selection.
|
|
2
2
|
import { heading } from "./tui/picker.js";
|
|
3
3
|
import { EMPTY } from "./tui/editor.js";
|
|
4
4
|
import { PROVIDERS } from "./providers/index.js";
|
|
@@ -31,21 +31,20 @@ export async function askForKey(name, host, pal) {
|
|
|
31
31
|
});
|
|
32
32
|
if (index === 0) {
|
|
33
33
|
hold(name, value);
|
|
34
|
-
host.emit({ kind: "notice", text: "
|
|
34
|
+
host.emit({ kind: "notice", text: "API key ready · this session", tone: "info" });
|
|
35
35
|
return true;
|
|
36
36
|
}
|
|
37
37
|
if (index === 1) {
|
|
38
38
|
try {
|
|
39
39
|
await keep(name, value);
|
|
40
|
-
host.emit({ kind: "notice", text:
|
|
40
|
+
host.emit({ kind: "notice", text: "API key saved", tone: "info" });
|
|
41
41
|
return true;
|
|
42
42
|
}
|
|
43
43
|
catch (error) {
|
|
44
|
-
host.emit({ kind: "notice", text: `could not save
|
|
44
|
+
host.emit({ kind: "notice", text: `could not save API key · ${error.message}`, tone: "error" });
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
host.emit({ kind: "notice", text: "credential discarded", tone: "warn" });
|
|
49
48
|
return false;
|
|
50
49
|
}
|
|
51
50
|
export async function credentialsCommand(session, host) {
|
|
@@ -70,7 +69,7 @@ export async function credentialsCommand(session, host) {
|
|
|
70
69
|
if (source === "environment") {
|
|
71
70
|
host.emit({
|
|
72
71
|
kind: "notice",
|
|
73
|
-
text: `${name} comes from the environment ·
|
|
72
|
+
text: `${name} comes from the environment · restart after changing it`,
|
|
74
73
|
tone: "info",
|
|
75
74
|
});
|
|
76
75
|
if (hasSaved(name))
|
|
@@ -80,14 +79,13 @@ export async function credentialsCommand(session, host) {
|
|
|
80
79
|
const actions = [
|
|
81
80
|
{ label: source === undefined ? "add credential" : "replace credential", key: "r" },
|
|
82
81
|
...(hasSaved(name) ? [{ label: "forget saved copy", hint: storeLabel(), key: "f" }] : []),
|
|
83
|
-
{ label: "close", key: "c" },
|
|
84
82
|
];
|
|
85
83
|
const action = await choose({
|
|
86
84
|
title: heading(name, source ?? "missing", session.palette),
|
|
87
85
|
options: actions,
|
|
88
86
|
index: 0,
|
|
89
87
|
});
|
|
90
|
-
if (action === undefined
|
|
88
|
+
if (action === undefined)
|
|
91
89
|
return;
|
|
92
90
|
if (actions[action]?.key === "f") {
|
|
93
91
|
await forget(name, host);
|
|
@@ -114,12 +112,12 @@ async function forget(name, host) {
|
|
|
114
112
|
const removed = await forgetSaved(name);
|
|
115
113
|
host.emit({
|
|
116
114
|
kind: "notice",
|
|
117
|
-
text: removed ? "
|
|
115
|
+
text: removed ? "API key removed" : "no saved API key",
|
|
118
116
|
tone: removed ? "info" : "warn",
|
|
119
117
|
});
|
|
120
118
|
}
|
|
121
119
|
catch (error) {
|
|
122
|
-
host.emit({ kind: "notice", text: `could not
|
|
120
|
+
host.emit({ kind: "notice", text: `could not remove API key · ${error.message}`, tone: "error" });
|
|
123
121
|
}
|
|
124
122
|
}
|
|
125
123
|
function chooser(host) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The session-local permission control plane exposed through /permissions.
|
|
2
|
+
import { heading } from "./tui/picker.js";
|
|
3
|
+
export async function permissionsCommand(session, host) {
|
|
4
|
+
const choose = host.choose;
|
|
5
|
+
const control = host.permissions;
|
|
6
|
+
if (choose === undefined || control === undefined) {
|
|
7
|
+
host.emit({ kind: "notice", text: "permissions need the interactive screen", tone: "warn" });
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
let selected = 0;
|
|
11
|
+
while (true) {
|
|
12
|
+
const tools = control.listTools();
|
|
13
|
+
const index = await choose(permissionsPicker(tools, session.palette, selected));
|
|
14
|
+
if (index === undefined)
|
|
15
|
+
return;
|
|
16
|
+
const tool = tools[index];
|
|
17
|
+
if (tool === undefined)
|
|
18
|
+
return;
|
|
19
|
+
selected = index;
|
|
20
|
+
await configureTool(tool, control, choose, session.palette);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function permissionsPicker(tools, pal, index = 0) {
|
|
24
|
+
const launchOverride = tools.some((tool) => tool.locked);
|
|
25
|
+
return {
|
|
26
|
+
title: heading("permissions", launchOverride ? "session only · auto approve at launch" : "session only", pal),
|
|
27
|
+
description: "Changes apply now · /new resets them",
|
|
28
|
+
options: tools.map((tool) => ({ label: tool.name, hint: toolHint(tool) })),
|
|
29
|
+
index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function configureTool(tool, control, choose, pal) {
|
|
33
|
+
if (tool.locked) {
|
|
34
|
+
await choose({
|
|
35
|
+
title: heading(tool.name, "launch override", pal),
|
|
36
|
+
description: "Restart without --auto-approve to change this tool",
|
|
37
|
+
options: [{ label: "allow", hint: "locked for this process" }],
|
|
38
|
+
index: 0,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const modes = tool.dangerous ? ["ask", "allow", "deny"] : ["allow", "deny"];
|
|
43
|
+
const grants = control.listGrants(tool.name);
|
|
44
|
+
const index = await choose({
|
|
45
|
+
title: heading(tool.name, tool.dangerous ? "dangerous tool" : "read-only tool", pal),
|
|
46
|
+
description: tool.dangerous
|
|
47
|
+
? "Session only · ask is the safe default"
|
|
48
|
+
: "Session only · deny hides this tool from the model",
|
|
49
|
+
options: [
|
|
50
|
+
...modes.map((mode) => ({ label: mode, hint: modeHint(mode, tool.dangerous) })),
|
|
51
|
+
...(grants.length === 0
|
|
52
|
+
? []
|
|
53
|
+
: [{ label: "remembered approvals", hint: String(grants.length) }]),
|
|
54
|
+
],
|
|
55
|
+
index: Math.max(0, modes.indexOf(tool.mode)),
|
|
56
|
+
});
|
|
57
|
+
if (index === undefined)
|
|
58
|
+
return;
|
|
59
|
+
const mode = modes[index];
|
|
60
|
+
if (mode !== undefined) {
|
|
61
|
+
control.set(tool.name, mode);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await reviewGrants(tool.name, control, choose, pal);
|
|
65
|
+
}
|
|
66
|
+
async function reviewGrants(tool, control, choose, pal) {
|
|
67
|
+
let selected = 0;
|
|
68
|
+
while (true) {
|
|
69
|
+
const grants = control.listGrants(tool);
|
|
70
|
+
if (grants.length === 0)
|
|
71
|
+
return;
|
|
72
|
+
const options = [
|
|
73
|
+
...grants.map((grant) => ({ label: grant.label, hint: "revoke" })),
|
|
74
|
+
...(grants.length > 1 ? [{ label: "all remembered approvals", hint: "revoke all" }] : []),
|
|
75
|
+
];
|
|
76
|
+
const index = await choose({
|
|
77
|
+
title: heading("remembered", tool, pal),
|
|
78
|
+
description: "Allowed without asking · this session",
|
|
79
|
+
options,
|
|
80
|
+
index: Math.min(selected, options.length - 1),
|
|
81
|
+
});
|
|
82
|
+
if (index === undefined)
|
|
83
|
+
return;
|
|
84
|
+
if (index === grants.length) {
|
|
85
|
+
control.revokeTool(tool);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const grant = grants[index];
|
|
89
|
+
if (grant === undefined)
|
|
90
|
+
return;
|
|
91
|
+
control.revoke(grant.key);
|
|
92
|
+
selected = index;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function toolHint(tool) {
|
|
96
|
+
const kind = tool.dangerous ? "" : " · read only";
|
|
97
|
+
const remembered = tool.remembered === 0 ? "" : ` · ${tool.remembered} remembered`;
|
|
98
|
+
const locked = tool.locked ? " · launch override" : "";
|
|
99
|
+
return `${tool.mode}${kind}${remembered}${locked}`;
|
|
100
|
+
}
|
|
101
|
+
function modeHint(mode, dangerous) {
|
|
102
|
+
if (mode === "deny")
|
|
103
|
+
return "hide from the model";
|
|
104
|
+
if (mode === "ask")
|
|
105
|
+
return "prompt when needed";
|
|
106
|
+
return dangerous ? "every call this session" : "offer to the model";
|
|
107
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Session-local tool policies and remembered approval scopes.
|
|
2
|
+
/** One permission control plane for one interactive process. */
|
|
3
|
+
export function sessionPermissions(tools, autoApprove) {
|
|
4
|
+
const catalogue = [...tools];
|
|
5
|
+
const byName = new Map(catalogue.map((tool) => [tool.name, tool]));
|
|
6
|
+
const modes = new Map();
|
|
7
|
+
const grants = new Map();
|
|
8
|
+
const configured = (tool) => modes.get(tool.name) ?? defaultMode(tool);
|
|
9
|
+
const effective = (tool) => autoApprove && tool.dangerous ? "allow" : configured(tool);
|
|
10
|
+
const revokeTool = (name) => {
|
|
11
|
+
for (const [key, grant] of grants) {
|
|
12
|
+
if (grant.tools.includes(name))
|
|
13
|
+
grants.delete(key);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
listTools() {
|
|
18
|
+
return catalogue.map((tool) => ({
|
|
19
|
+
name: tool.name,
|
|
20
|
+
dangerous: tool.dangerous,
|
|
21
|
+
mode: effective(tool),
|
|
22
|
+
remembered: [...grants.values()].filter((grant) => grant.tools.includes(tool.name)).length,
|
|
23
|
+
locked: autoApprove && tool.dangerous,
|
|
24
|
+
}));
|
|
25
|
+
},
|
|
26
|
+
set(name, mode) {
|
|
27
|
+
const tool = byName.get(name);
|
|
28
|
+
if (tool === undefined || (autoApprove && tool.dangerous))
|
|
29
|
+
return false;
|
|
30
|
+
if (!tool.dangerous && mode === "ask")
|
|
31
|
+
return false;
|
|
32
|
+
if (configured(tool) === mode)
|
|
33
|
+
return true;
|
|
34
|
+
if (mode === defaultMode(tool))
|
|
35
|
+
modes.delete(name);
|
|
36
|
+
else
|
|
37
|
+
modes.set(name, mode);
|
|
38
|
+
revokeTool(name);
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
listGrants(name) {
|
|
42
|
+
return [...grants.values()].filter((grant) => name === undefined || grant.tools.includes(name));
|
|
43
|
+
},
|
|
44
|
+
revoke(key) {
|
|
45
|
+
grants.delete(key);
|
|
46
|
+
},
|
|
47
|
+
revokeTool,
|
|
48
|
+
reset() {
|
|
49
|
+
modes.clear();
|
|
50
|
+
grants.clear();
|
|
51
|
+
},
|
|
52
|
+
availableTools() {
|
|
53
|
+
return catalogue.filter((tool) => effective(tool) !== "deny");
|
|
54
|
+
},
|
|
55
|
+
approved(call) {
|
|
56
|
+
const tool = byName.get(call.name);
|
|
57
|
+
if (tool === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
const mode = effective(tool);
|
|
60
|
+
if (mode === "allow")
|
|
61
|
+
return true;
|
|
62
|
+
if (mode === "deny")
|
|
63
|
+
return false;
|
|
64
|
+
return grants.has(scopeFor(call).key);
|
|
65
|
+
},
|
|
66
|
+
remember(call) {
|
|
67
|
+
const tool = byName.get(call.name);
|
|
68
|
+
if (tool === undefined || effective(tool) !== "ask")
|
|
69
|
+
return;
|
|
70
|
+
const scope = scopeFor(call);
|
|
71
|
+
grants.set(scope.key, { key: scope.key, tools: grantTools(call), label: scope.summary });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** The narrow permission represented by "for the session" in an approval. */
|
|
76
|
+
export function scopeFor(call) {
|
|
77
|
+
const path = typeof call.input.path === "string" ? call.input.path : undefined;
|
|
78
|
+
if ((call.name === "write_file" || call.name === "edit_file") && path !== undefined) {
|
|
79
|
+
return { key: `file\0${path}`, label: `changes to ${path}`, summary: `file changes · ${path}` };
|
|
80
|
+
}
|
|
81
|
+
const command = typeof call.input.command === "string" ? call.input.command : undefined;
|
|
82
|
+
if (call.name === "run_command" && command !== undefined) {
|
|
83
|
+
return { key: `command\0${command}`, label: "this exact command", summary: `command · ${command}` };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
key: `${call.name}\0${stable(call.input)}`,
|
|
87
|
+
label: "this exact call",
|
|
88
|
+
summary: `${call.name} · ${target(call.input)}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function defaultMode(tool) {
|
|
92
|
+
return tool.dangerous ? "ask" : "allow";
|
|
93
|
+
}
|
|
94
|
+
function grantTools(call) {
|
|
95
|
+
return call.name === "write_file" || call.name === "edit_file"
|
|
96
|
+
? ["edit_file", "write_file"]
|
|
97
|
+
: [call.name];
|
|
98
|
+
}
|
|
99
|
+
function stable(value) {
|
|
100
|
+
if (Array.isArray(value))
|
|
101
|
+
return `[${value.map(stable).join(",")}]`;
|
|
102
|
+
if (value !== null && typeof value === "object") {
|
|
103
|
+
return `{${Object.entries(value)
|
|
104
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
105
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`)
|
|
106
|
+
.join(",")}}`;
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify(value);
|
|
109
|
+
}
|
|
110
|
+
function target(input) {
|
|
111
|
+
const value = input.path ?? input.command;
|
|
112
|
+
return typeof value === "string" ? value : stable(input);
|
|
113
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Provider
|
|
1
|
+
// Provider and model command flows.
|
|
2
2
|
import { heading } from "./tui/picker.js";
|
|
3
3
|
import { PROVIDERS } from "./providers/index.js";
|
|
4
4
|
import { readSettings } from "./settings.js";
|
|
@@ -10,7 +10,7 @@ import { providerFailure } from "./provider-errors.js";
|
|
|
10
10
|
* Every provider is offered, including the ones that cannot run: the reason
|
|
11
11
|
* one is unusable — the variable it wants, by name — is worth more on screen
|
|
12
12
|
* than the row would be worth hidden. A blocked choice opens the same masked
|
|
13
|
-
* credential flow used by
|
|
13
|
+
* credential flow used by settings, and cancellation leaves the old choice.
|
|
14
14
|
*/
|
|
15
15
|
export async function providersCommand(session, host, behavior = {}) {
|
|
16
16
|
const choose = chooser(host);
|
|
@@ -93,7 +93,7 @@ export async function providersCommand(session, host, behavior = {}) {
|
|
|
93
93
|
if (behavior.announce !== false) {
|
|
94
94
|
host.emit({
|
|
95
95
|
kind: "notice",
|
|
96
|
-
text: session.model === "" ?
|
|
96
|
+
text: session.model === "" ? `provider · ${chosen.id} · pick a model` : `provider · ${chosen.id}`,
|
|
97
97
|
tone: "info",
|
|
98
98
|
});
|
|
99
99
|
}
|
|
@@ -171,38 +171,10 @@ export async function modelsCommand(session, host, behavior = {}) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
if (behavior.announce !== false) {
|
|
174
|
-
host.emit({ kind: "notice", text:
|
|
174
|
+
host.emit({ kind: "notice", text: `model · ${chosen}`, tone: "info" });
|
|
175
175
|
}
|
|
176
176
|
return true;
|
|
177
177
|
}
|
|
178
|
-
/** Make the provider selected by flags/environment usable without leaving the TUI. */
|
|
179
|
-
export async function setupCommand(session, host) {
|
|
180
|
-
const blocked = session.provider.blocked();
|
|
181
|
-
if (blocked !== undefined) {
|
|
182
|
-
if (!isCredentialBlocker(session.provider, blocked)) {
|
|
183
|
-
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const accepted = await askForKey(session.provider.keyVar, host, session.palette);
|
|
187
|
-
if (!accepted || session.provider.blocked() !== undefined) {
|
|
188
|
-
host.emit({
|
|
189
|
-
kind: "notice",
|
|
190
|
-
text: `${providerName(session.provider.id)} still needs an API key · /setup`,
|
|
191
|
-
tone: "warn",
|
|
192
|
-
});
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (session.model === "") {
|
|
197
|
-
await modelsCommand(session, host);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
host.emit({
|
|
201
|
-
kind: "notice",
|
|
202
|
-
text: `${session.provider.id} · ${session.model} · ${session.provider.location?.() ?? "cloud"} · ready`,
|
|
203
|
-
tone: "info",
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
178
|
/** The way to put a menu up, or nothing — and the reason, already said. */
|
|
207
179
|
function chooser(host) {
|
|
208
180
|
if (host.choose === undefined) {
|
package/dist/settings-command.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function settingsCommand(session, host) {
|
|
|
25
25
|
if (index === undefined)
|
|
26
26
|
return;
|
|
27
27
|
const action = items[index]?.action;
|
|
28
|
-
if (action === undefined
|
|
28
|
+
if (action === undefined)
|
|
29
29
|
return;
|
|
30
30
|
selected = index;
|
|
31
31
|
switch (action) {
|
|
@@ -82,7 +82,6 @@ function settingsItems(values) {
|
|
|
82
82
|
option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
|
|
83
83
|
},
|
|
84
84
|
{ action: "credentials", option: { label: "credentials", hint: "manage API keys" } },
|
|
85
|
-
{ action: "close", option: { label: "close" } },
|
|
86
85
|
];
|
|
87
86
|
}
|
|
88
87
|
function settingsValues(session) {
|
|
@@ -187,7 +186,7 @@ async function persist(host, patch) {
|
|
|
187
186
|
return true;
|
|
188
187
|
}
|
|
189
188
|
catch (error) {
|
|
190
|
-
host.emit({ kind: "notice", text: `could not save settings
|
|
189
|
+
host.emit({ kind: "notice", text: `could not save settings · ${error.message}`, tone: "error" });
|
|
191
190
|
return false;
|
|
192
191
|
}
|
|
193
192
|
}
|
package/dist/tools/shell.js
CHANGED
|
@@ -8,7 +8,7 @@ const MAX_OUTPUT_CHARS = 30_000;
|
|
|
8
8
|
export const runCommand = {
|
|
9
9
|
name: "run_command",
|
|
10
10
|
description: "Run a shell command starting in the workspace root and return its combined stdout " +
|
|
11
|
-
"and stderr. The shell is not a filesystem sandbox, so
|
|
11
|
+
"and stderr. The shell is not a filesystem sandbox, so calls ask for approval by default. Output is " +
|
|
12
12
|
"truncated past 30000 characters.",
|
|
13
13
|
dangerous: true,
|
|
14
14
|
input: {
|
package/dist/transcript.js
CHANGED
|
@@ -25,9 +25,6 @@ export function transcriptMarkdown(blocks) {
|
|
|
25
25
|
case "notice":
|
|
26
26
|
out.push(`> ${block.tone.toUpperCase()}: ${safeMultiline(block.text).replaceAll("\n", "\n> ")}`, "");
|
|
27
27
|
break;
|
|
28
|
-
case "list":
|
|
29
|
-
out.push(...block.items.map((item) => safeInline(item.text)), "");
|
|
30
|
-
break;
|
|
31
28
|
}
|
|
32
29
|
}
|
|
33
30
|
while (out[out.length - 1] === "")
|
|
@@ -4,20 +4,24 @@ import { runTurn } from "../controller.js";
|
|
|
4
4
|
import { updateSettings } from "../settings.js";
|
|
5
5
|
import { saveTranscript } from "../transcript-export.js";
|
|
6
6
|
import { recordUsage } from "../usage.js";
|
|
7
|
-
import { answerAt
|
|
7
|
+
import { answerAt } from "./approve.js";
|
|
8
8
|
import { controllerOptions, turnFailure } from "./session-view.js";
|
|
9
9
|
import { transcribe } from "./turn.js";
|
|
10
10
|
const WAITING = "Waiting";
|
|
11
11
|
export function appWorkflows(options) {
|
|
12
|
-
const { session, state,
|
|
12
|
+
const { session, state, permissions, feedback } = options;
|
|
13
13
|
async function command(text) {
|
|
14
14
|
const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
|
|
15
15
|
if (activity === undefined)
|
|
16
16
|
return;
|
|
17
17
|
try {
|
|
18
18
|
const outcome = await handleCommand(text, session, {
|
|
19
|
-
emit: options.
|
|
19
|
+
emit: options.commandNotice,
|
|
20
20
|
signal: activity.control.signal,
|
|
21
|
+
showHelp: () => new Promise((resolve) => {
|
|
22
|
+
state.open = { help: true, settle: resolve };
|
|
23
|
+
options.render();
|
|
24
|
+
}),
|
|
21
25
|
choose: (picker) => new Promise((resolve) => {
|
|
22
26
|
state.open = { picker, settle: resolve };
|
|
23
27
|
options.render();
|
|
@@ -33,19 +37,13 @@ export function appWorkflows(options) {
|
|
|
33
37
|
reset: () => {
|
|
34
38
|
state.blocks.splice(0);
|
|
35
39
|
state.past.length = 0;
|
|
36
|
-
|
|
40
|
+
permissions.reset();
|
|
37
41
|
state.scroll = 0;
|
|
38
42
|
state.follow = true;
|
|
39
43
|
state.unseen = 0;
|
|
40
44
|
state.lastMaxScroll = 0;
|
|
41
45
|
},
|
|
42
|
-
permissions
|
|
43
|
-
revokePermission: (key) => {
|
|
44
|
-
if (key === undefined)
|
|
45
|
-
allowed.clear();
|
|
46
|
-
else
|
|
47
|
-
allowed.delete(key);
|
|
48
|
-
},
|
|
46
|
+
permissions,
|
|
49
47
|
exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
|
|
50
48
|
saveSettings: async (patch) => {
|
|
51
49
|
await updateSettings(patch);
|
|
@@ -76,11 +74,8 @@ export function appWorkflows(options) {
|
|
|
76
74
|
emit: options.emit,
|
|
77
75
|
render: options.render,
|
|
78
76
|
palette: session.palette,
|
|
79
|
-
approved: (call) =>
|
|
80
|
-
remember: (call) =>
|
|
81
|
-
const scope = scopeFor(call);
|
|
82
|
-
allowed.set(scope.key, scope.summary);
|
|
83
|
-
},
|
|
77
|
+
approved: (call) => permissions.approved(call),
|
|
78
|
+
remember: (call) => permissions.remember(call),
|
|
84
79
|
ask: (prompt, settle) => {
|
|
85
80
|
state.open = { picker: prompt, settle: (index) => settle(answerAt(index)) };
|
|
86
81
|
options.render();
|
|
@@ -92,7 +87,7 @@ export function appWorkflows(options) {
|
|
|
92
87
|
});
|
|
93
88
|
let finishReason;
|
|
94
89
|
try {
|
|
95
|
-
await runTurn(session.history, controllerOptions(session), events, activity.control.signal);
|
|
90
|
+
await runTurn(session.history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal);
|
|
96
91
|
}
|
|
97
92
|
catch (error) {
|
|
98
93
|
const interrupted = activity.control.signal.aborted;
|
package/dist/tui/app.js
CHANGED
|
@@ -18,6 +18,7 @@ import { transcriptRenderer } from "./transcript-view.js";
|
|
|
18
18
|
import { appState } from "./app-state.js";
|
|
19
19
|
import { appInput } from "./app-input.js";
|
|
20
20
|
import { appWorkflows } from "./app-workflows.js";
|
|
21
|
+
import { sessionPermissions } from "../permissions.js";
|
|
21
22
|
const FRAME_MS = 16;
|
|
22
23
|
const SPIN_MS = 80;
|
|
23
24
|
/** How long a lone escape waits to prove it is not the start of a sequence. */
|
|
@@ -29,9 +30,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
29
30
|
const transcript = transcriptRenderer();
|
|
30
31
|
const workspace = await workspaceLabel(session.config.root);
|
|
31
32
|
const state = appState();
|
|
32
|
-
|
|
33
|
-
// a permission granted once, in one conversation, is not a setting.
|
|
34
|
-
const allowed = new Map();
|
|
33
|
+
const permissions = sessionPermissions(session.tools, session.config.autoApprove);
|
|
35
34
|
let closed;
|
|
36
35
|
let frameTimer;
|
|
37
36
|
let spinTimer;
|
|
@@ -100,12 +99,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
100
99
|
else
|
|
101
100
|
state.unseen++;
|
|
102
101
|
};
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
if (next === undefined)
|
|
106
|
-
emit(block);
|
|
107
|
-
else
|
|
108
|
-
feedback.show(next);
|
|
102
|
+
const commandNotice = (notice) => {
|
|
103
|
+
feedback.show(commandFeedback(notice));
|
|
109
104
|
};
|
|
110
105
|
const scrollBy = (amount) => {
|
|
111
106
|
state.scroll = Math.max(0, state.scroll + amount);
|
|
@@ -177,10 +172,10 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
177
172
|
session,
|
|
178
173
|
transcriptRoot,
|
|
179
174
|
state,
|
|
180
|
-
|
|
175
|
+
permissions,
|
|
181
176
|
feedback,
|
|
182
177
|
emit,
|
|
183
|
-
|
|
178
|
+
commandNotice,
|
|
184
179
|
render,
|
|
185
180
|
refreshSettings: () => {
|
|
186
181
|
terminal.setReducedMotion(session.config.reducedMotion);
|
package/dist/tui/approve.js
CHANGED
|
@@ -4,28 +4,15 @@
|
|
|
4
4
|
// worth more than a line of prose and a key to guess at. It is a menu — the
|
|
5
5
|
// generic one — with an attention title and three answers written out.
|
|
6
6
|
// Nothing is approved by a key nobody meant to press.
|
|
7
|
+
import { scopeFor } from "../permissions.js";
|
|
8
|
+
export { scopeFor };
|
|
7
9
|
/**
|
|
8
|
-
* The
|
|
10
|
+
* The three answers, in the order a person actually wants them.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* "Always" is scoped to the tool and to this session: it is a way to stop being
|
|
13
|
+
* asked about `write_file` twenty times in a row, not a setting that outlives
|
|
14
|
+
* the window it was granted in.
|
|
13
15
|
*/
|
|
14
|
-
export function scopeFor(call) {
|
|
15
|
-
const path = typeof call.input.path === "string" ? call.input.path : undefined;
|
|
16
|
-
if ((call.name === "write_file" || call.name === "edit_file") && path !== undefined) {
|
|
17
|
-
return { key: `file\0${path}`, label: `changes to ${path}`, summary: `file changes · ${path}` };
|
|
18
|
-
}
|
|
19
|
-
const command = typeof call.input.command === "string" ? call.input.command : undefined;
|
|
20
|
-
if (call.name === "run_command" && command !== undefined) {
|
|
21
|
-
return { key: `command\0${command}`, label: "this exact command", summary: `command · ${command}` };
|
|
22
|
-
}
|
|
23
|
-
return {
|
|
24
|
-
key: `${call.name}\0${stable(call.input)}`,
|
|
25
|
-
label: "this exact call",
|
|
26
|
-
summary: `${call.name} · ${target(call.input)}`,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
16
|
export function promptFor(call, target, pal) {
|
|
30
17
|
const scope = scopeFor(call);
|
|
31
18
|
const options = [
|
|
@@ -56,21 +43,6 @@ function scopeNoun(scope) {
|
|
|
56
43
|
return "this command";
|
|
57
44
|
return "this call";
|
|
58
45
|
}
|
|
59
|
-
function stable(value) {
|
|
60
|
-
if (Array.isArray(value))
|
|
61
|
-
return `[${value.map(stable).join(",")}]`;
|
|
62
|
-
if (value !== null && typeof value === "object") {
|
|
63
|
-
return `{${Object.entries(value)
|
|
64
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
65
|
-
.map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`)
|
|
66
|
-
.join(",")}}`;
|
|
67
|
-
}
|
|
68
|
-
return JSON.stringify(value);
|
|
69
|
-
}
|
|
70
|
-
function target(input) {
|
|
71
|
-
const value = input.path ?? input.command;
|
|
72
|
-
return typeof value === "string" ? value : stable(input);
|
|
73
|
-
}
|
|
74
46
|
const ANSWERS = ["once", "always", "no"];
|
|
75
47
|
/** What picking row `index` meant. Anything unrecognised refuses. */
|
|
76
48
|
export function answerAt(index) {
|
package/dist/tui/blocks.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Semantic transcript blocks routed to small, owned production components.
|
|
2
2
|
import { renderAnswer, renderReasoning, renderUser } from "./components/messages.js";
|
|
3
|
-
import {
|
|
3
|
+
import { renderNotice } from "./components/misc.js";
|
|
4
4
|
import { renderTool } from "./components/tool.js";
|
|
5
5
|
export function render(block, width, pal, context = {}) {
|
|
6
6
|
switch (block.kind) {
|
|
@@ -19,8 +19,6 @@ export function render(block, width, pal, context = {}) {
|
|
|
19
19
|
});
|
|
20
20
|
case "notice":
|
|
21
21
|
return renderNotice(block, width, pal);
|
|
22
|
-
case "list":
|
|
23
|
-
return renderList(block, width, pal);
|
|
24
22
|
}
|
|
25
23
|
}
|
|
26
24
|
export function renderAll(blocks, width, pal, context = {}) {
|
package/dist/tui/complete.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Completing a slash command.
|
|
2
2
|
//
|
|
3
|
-
// A command the user cannot remember is a command that does not exist
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// A command the user cannot remember is a command that does not exist. `/help`
|
|
4
|
+
// owns keyboard controls; discovery belongs here, while the line is being
|
|
5
|
+
// typed. Selection is state, not an edit: arrows move through the list without
|
|
6
|
+
// rewriting what the user typed.
|
|
7
7
|
import { COMMANDS } from "../commands.js";
|
|
8
8
|
/**
|
|
9
9
|
* The commands a half-typed line still matches.
|
|
@@ -16,10 +16,11 @@ 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
|
-
// Colour terminals use one quiet selection band
|
|
20
|
-
//
|
|
19
|
+
// Colour terminals use one quiet selection band and keep every label on the
|
|
20
|
+
// composer's content edge. Monochrome has no band, so it alone reserves a
|
|
21
|
+
// fixed arrow column to keep selection visible without shifting peer rows.
|
|
21
22
|
const monochrome = !hasColor();
|
|
22
|
-
const selectedMark = entry.selected
|
|
23
|
+
const selectedMark = monochrome ? (entry.selected ? "→ " : " ") : "";
|
|
23
24
|
const fg = entry.selected ? pal.ink.bright : pal.ink.fg;
|
|
24
25
|
const primary = [
|
|
25
26
|
{ text: selectedMark, fg: entry.selected ? pal.accent : fg },
|
|
@@ -14,9 +14,3 @@ export function renderNotice(block, width, pal) {
|
|
|
14
14
|
], [], undefined, 1)),
|
|
15
15
|
];
|
|
16
16
|
}
|
|
17
|
-
export function renderList(block, width, pal) {
|
|
18
|
-
return [
|
|
19
|
-
"",
|
|
20
|
-
...block.items.map((item) => row(width, [{ text: item.text, fg: item.dim ? pal.ink.muted : pal.ink.fg }], [], undefined, 1)),
|
|
21
|
-
];
|
|
22
|
-
}
|
|
@@ -16,15 +16,14 @@ export function renderStatus(info, pal) {
|
|
|
16
16
|
return info.readiness === undefined ? [] : feedbackSegments(info.readiness, pal);
|
|
17
17
|
}
|
|
18
18
|
function feedbackSegments(feedback, pal) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
19
|
+
if (feedback.tone === "info") {
|
|
20
|
+
return [{ text: feedback.text, fg: pal.ink.muted }];
|
|
21
|
+
}
|
|
22
|
+
const mark = feedback.tone === "error" ? "×" : "!";
|
|
23
|
+
const markColor = feedback.tone === "error" ? pal.ink.removed : pal.ink.attention;
|
|
25
24
|
const textColor = feedback.tone === "error" ? pal.ink.removed : pal.ink.muted;
|
|
26
25
|
return [
|
|
27
|
-
{ text: `${mark} `, fg: markColor
|
|
26
|
+
{ text: `${mark} `, fg: markColor, bold: true },
|
|
28
27
|
{ text: feedback.text, fg: textColor },
|
|
29
28
|
];
|
|
30
29
|
}
|
package/dist/tui/feedback.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Operational feedback belongs in the footer, not in the conversation.
|
|
2
|
-
const INFO_MS =
|
|
2
|
+
const INFO_MS = 2_200;
|
|
3
3
|
const WARN_MS = 4_200;
|
|
4
4
|
const ERROR_MS = 6_000;
|
|
5
5
|
/** Own replacement and expiry without leaking timers into the TUI shell. */
|
|
@@ -41,8 +41,6 @@ export function feedbackController(changed) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Turn a command notice into one replaceable message in the footer status channel. */
|
|
43
43
|
export function commandFeedback(block) {
|
|
44
|
-
if (block.kind !== "notice")
|
|
45
|
-
return undefined;
|
|
46
44
|
return {
|
|
47
45
|
text: block.text,
|
|
48
46
|
tone: block.tone,
|
package/dist/tui/help.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The compact, non-persistent keyboard reference shown inside the dock.
|
|
2
|
+
import { row } from "../ui/render.js";
|
|
3
|
+
import { textWidth } from "../ui/width.js";
|
|
4
|
+
const CONTROL_WIDTH = 18;
|
|
5
|
+
const CONTROLS = [
|
|
6
|
+
{ key: "up / down", description: "move through menus or history" },
|
|
7
|
+
{ key: "enter / tab", description: "select or send · complete" },
|
|
8
|
+
{ key: "alt+enter", description: "insert a new line" },
|
|
9
|
+
{ key: "esc", description: "close UI or interrupt work" },
|
|
10
|
+
{ key: "ctrl+c", description: "interrupt work or exit" },
|
|
11
|
+
{ key: "ctrl+o", description: "toggle reasoning or tool details" },
|
|
12
|
+
{ key: "wheel / pgup/dn", description: "scroll the transcript" },
|
|
13
|
+
{ key: "ctrl+l", description: "redraw the screen" },
|
|
14
|
+
];
|
|
15
|
+
export function panel(width, pal, maxRows = CONTROLS.length + 1) {
|
|
16
|
+
const heading = row(width, [
|
|
17
|
+
{ text: "help ", fg: pal.accent, bold: true },
|
|
18
|
+
{ text: "keyboard controls", fg: pal.ink.fg },
|
|
19
|
+
], [{ text: "esc close", fg: pal.ink.muted }]);
|
|
20
|
+
const controls = CONTROLS.map((control) => {
|
|
21
|
+
const gap = Math.max(2, CONTROL_WIDTH - textWidth(control.key));
|
|
22
|
+
return row(width, [
|
|
23
|
+
{ text: control.key, fg: pal.ink.bright, bold: true },
|
|
24
|
+
{ text: " ".repeat(gap) },
|
|
25
|
+
{ text: control.description, fg: pal.ink.muted },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
return [heading, ...controls].slice(0, Math.max(1, maxRows));
|
|
29
|
+
}
|
package/dist/tui/modal.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
// What can take the dock over, and how it draws.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Three kinds, and the union exists so that everything between the command that
|
|
4
4
|
// opens one and the frame that draws it — the view, the shell, the key handler
|
|
5
|
-
// — speaks about "the thing that is open" rather than about
|
|
6
|
-
//
|
|
5
|
+
// — speaks about "the thing that is open" rather than about each interaction
|
|
6
|
+
// separately.
|
|
7
7
|
import * as picker from "./picker.js";
|
|
8
8
|
import * as field from "./field.js";
|
|
9
|
+
import * as help from "./help.js";
|
|
9
10
|
export function panel(modal, width, pal, maxRows) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
switch (modal.kind) {
|
|
12
|
+
case "pick":
|
|
13
|
+
return picker.panel(modal.picker, width, pal, maxRows);
|
|
14
|
+
case "type":
|
|
15
|
+
return maxRows === undefined
|
|
16
|
+
? field.panel(modal.field, width, pal)
|
|
17
|
+
: field.panel(modal.field, width, pal).slice(0, maxRows);
|
|
18
|
+
case "help":
|
|
19
|
+
return help.panel(width, pal, maxRows);
|
|
20
|
+
}
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
17
23
|
* Where the caret goes while this is open, if it goes anywhere.
|
|
@@ -20,7 +26,12 @@ export function panel(modal, width, pal, maxRows) {
|
|
|
20
26
|
* query prompt and place the terminal caret at its real editing position.
|
|
21
27
|
*/
|
|
22
28
|
export function caret(modal, width, maxRows) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
switch (modal.kind) {
|
|
30
|
+
case "pick":
|
|
31
|
+
return picker.caret(modal.picker, width, maxRows);
|
|
32
|
+
case "type":
|
|
33
|
+
return field.caret(modal.field, width);
|
|
34
|
+
case "help":
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
26
37
|
}
|
package/dist/tui/overlay.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
// The modal interaction layer:
|
|
1
|
+
// The modal interaction layer: a picker, a single-line field, or read-only help.
|
|
2
2
|
import * as picker from "./picker.js";
|
|
3
3
|
import { oneLine } from "./field.js";
|
|
4
4
|
import { applyKey } from "./input.js";
|
|
5
5
|
export function shown(open) {
|
|
6
6
|
if (open === undefined)
|
|
7
7
|
return undefined;
|
|
8
|
-
|
|
8
|
+
if ("picker" in open)
|
|
9
|
+
return { kind: "pick", picker: open.picker };
|
|
10
|
+
if ("field" in open)
|
|
11
|
+
return { kind: "type", field: open.field };
|
|
12
|
+
return { kind: "help" };
|
|
9
13
|
}
|
|
10
14
|
export function cancel(open) {
|
|
11
15
|
if (open === undefined)
|
|
12
16
|
return undefined;
|
|
13
|
-
open
|
|
17
|
+
if ("help" in open)
|
|
18
|
+
open.settle();
|
|
19
|
+
else
|
|
20
|
+
open.settle(undefined);
|
|
14
21
|
return undefined;
|
|
15
22
|
}
|
|
16
23
|
export function handle(open, key) {
|
|
@@ -26,7 +33,11 @@ export function handle(open, key) {
|
|
|
26
33
|
cancel(open);
|
|
27
34
|
return {};
|
|
28
35
|
}
|
|
29
|
-
|
|
36
|
+
if ("picker" in open)
|
|
37
|
+
return handlePicker(open, key);
|
|
38
|
+
if ("field" in open)
|
|
39
|
+
return handleField(open, key);
|
|
40
|
+
return { open };
|
|
30
41
|
}
|
|
31
42
|
function handlePicker(open, key) {
|
|
32
43
|
switch (key.name) {
|
package/dist/tui/picker.js
CHANGED
|
@@ -64,7 +64,7 @@ function layout(picker, width, pal, maxRows) {
|
|
|
64
64
|
const options = colors === undefined
|
|
65
65
|
? []
|
|
66
66
|
: shown.length === 0
|
|
67
|
-
? [row(width, [{ text: "
|
|
67
|
+
? [row(width, [{ text: "no matches", fg: colors.ink.muted }])]
|
|
68
68
|
: renderMenuRows(shown.map(({ option, index }) => ({
|
|
69
69
|
label: option.label,
|
|
70
70
|
hint: option.hint,
|
package/dist/tui/session-view.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Small projections of the live session used by the shell and footer.
|
|
2
2
|
import { credentialSource } from "../credentials.js";
|
|
3
3
|
import { providerFailure } from "../provider-errors.js";
|
|
4
|
-
export function controllerOptions(session) {
|
|
4
|
+
export function controllerOptions(session, tools = session.tools) {
|
|
5
5
|
return {
|
|
6
6
|
provider: session.provider,
|
|
7
|
-
tools
|
|
7
|
+
tools,
|
|
8
8
|
model: session.model,
|
|
9
9
|
system: session.system,
|
|
10
10
|
maxTokens: session.config.maxTokens,
|