@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 CHANGED
@@ -3,11 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <picture>
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 | Review or revoke remembered session approvals |
179
- | /usage | Show normalized token usage |
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 | Show commands and controls |
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 require approval unless the process was started with
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 { heading } from "./tui/picker.js";
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, formatTokens } from "./usage.js";
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, credential, and setup commands remain useful direct
21
- * paths into the same interactions.
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: "this list" },
20
+ { name: "help", blurb: "show keyboard controls" },
25
21
  { name: "exit", blurb: "exit and restore the terminal" },
26
- { name: "new", blurb: "start a clean in-memory session" },
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: "review or revoke remembered approvals" },
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.emit(help());
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 · history, usage, and approvals cleared", tone: "info" });
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: `transcript saved to ${saved}`, tone: "info" });
60
+ host.emit({ kind: "notice", text: `saved · ${saved}`, tone: "info" });
61
61
  }
62
62
  return "handled";
63
63
  case "permissions":
64
- await permissions(session, host);
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({ kind: "notice", text: `unknown command /${name ?? ""} — try /help`, tone: "warn" });
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 setup and provider selection.
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: "credential available for this session", tone: "info" });
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: `credential saved · ${storeLabel()}`, tone: "info" });
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 credential · ${error.message}`, tone: "error" });
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 · update it outside jecode and restart`,
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 || action === actions.length - 1)
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 ? "saved credential removed" : "no saved credential to remove",
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 forget: ${error.message}`, tone: "error" });
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, model, and setup command flows.
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 setup and settings, and cancellation leaves the old choice.
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 === "" ? `${chosen.id} pick a model with /models` : `${chosen.id} · ${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: `${provider.id} · ${chosen}`, tone: "info" });
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) {
@@ -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 || action === "close")
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: ${error.message}`, tone: "error" });
189
+ host.emit({ kind: "notice", text: `could not save settings · ${error.message}`, tone: "error" });
191
190
  return false;
192
191
  }
193
192
  }
@@ -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 every call requires approval. Output is " +
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: {
@@ -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, scopeFor } from "./approve.js";
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, allowed, feedback } = options;
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.commandOutput,
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
- allowed.clear();
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: () => [...allowed].map(([key, label]) => ({ key, label })),
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) => session.config.autoApprove || allowed.has(scopeFor(call).key),
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
- // Tools the user said "always" to. It lives for the window and dies with it:
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 commandOutput = (block) => {
104
- const next = commandFeedback(block);
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
- allowed,
175
+ permissions,
181
176
  feedback,
182
177
  emit,
183
- commandOutput,
178
+ commandNotice,
184
179
  render,
185
180
  refreshSettings: () => {
186
181
  terminal.setReducedMotion(session.config.reducedMotion);
@@ -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 narrow permission represented by "always".
10
+ * The three answers, in the order a person actually wants them.
9
11
  *
10
- * File-changing tools share a grant for one displayed path. Shell grants are
11
- * tied to the exact command line. Unknown dangerous tools fall back to their
12
- * exact, stable input rather than silently inheriting a tool-wide grant.
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) {
@@ -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 { renderList, renderNotice } from "./components/misc.js";
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 = {}) {
@@ -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, and
4
- // `/help` only helps someone who already knows to ask. So the menu appears
5
- // while the line is being typed. Selection is state, not an edit: arrows move
6
- // through the list without rewriting what the user typed.
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. In monochrome the arrow is
20
- // structural rather than decorative, so selection remains unambiguous.
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 && monochrome ? "→ " : " ";
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
- const mark = feedback.tone === "error" ? "×" : feedback.tone === "warn" ? "!" : "·";
20
- const markColor = {
21
- info: pal.accent,
22
- warn: pal.ink.attention,
23
- error: pal.ink.removed,
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[feedback.tone], bold: true },
26
+ { text: `${mark} `, fg: markColor, bold: true },
28
27
  { text: feedback.text, fg: textColor },
29
28
  ];
30
29
  }
@@ -1,5 +1,5 @@
1
1
  // Operational feedback belongs in the footer, not in the conversation.
2
- const INFO_MS = 2_800;
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,
@@ -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
- // Two kinds, and the union exists so that everything between the command that
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 a picker and a
6
- // field separately. A third kind would be added here and nowhere else.
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
- return modal.kind === "pick"
11
- ? picker.panel(modal.picker, width, pal, maxRows)
12
- : maxRows === undefined
13
- ? field.panel(modal.field, width, pal)
14
- : field.panel(modal.field, width, pal).slice(0, maxRows);
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
- return modal.kind === "type"
24
- ? field.caret(modal.field, width)
25
- : picker.caret(modal.picker, width, maxRows);
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
  }
@@ -1,16 +1,23 @@
1
- // The modal interaction layer: one picker or one single-line field.
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
- return "picker" in open ? { kind: "pick", picker: open.picker } : { kind: "type", field: open.field };
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.settle(undefined);
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
- return "picker" in open ? handlePicker(open, key) : handleField(open, key);
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) {
@@ -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: " no matches", fg: colors.ink.muted }])]
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,
@@ -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: session.tools,
7
+ tools,
8
8
  model: session.model,
9
9
  system: session.system,
10
10
  maxTokens: session.config.maxTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {