@giovannijecha/jecode 0.1.6-rc.0 → 0.1.6

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