@giovannijecha/jecode 0.8.5 → 0.8.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.
Files changed (64) hide show
  1. package/README.md +22 -5
  2. package/dist/batch-view.js +27 -2
  3. package/dist/batch.js +2 -0
  4. package/dist/cli-info.js +0 -1
  5. package/dist/config.js +14 -6
  6. package/dist/credential-commands.js +3 -3
  7. package/dist/openai-account-command.js +14 -12
  8. package/dist/openai-account.js +7 -5
  9. package/dist/openai-oauth-callback.js +13 -4
  10. package/dist/openai-oauth-tokens.js +9 -7
  11. package/dist/openai-oauth.js +8 -6
  12. package/dist/permission-command.js +1 -1
  13. package/dist/provider-commands.js +1 -33
  14. package/dist/provider-errors.js +1 -10
  15. package/dist/provider-label.js +3 -10
  16. package/dist/providers/anthropic.js +0 -1
  17. package/dist/providers/index.js +1 -5
  18. package/dist/providers/ollama-context.js +42 -0
  19. package/dist/providers/ollama-endpoint.js +7 -34
  20. package/dist/providers/ollama.js +13 -147
  21. package/dist/providers/openai-codex.js +4 -4
  22. package/dist/providers/openai.js +0 -1
  23. package/dist/sessions/bucket.js +55 -0
  24. package/dist/sessions/catalog-io.js +162 -0
  25. package/dist/sessions/catalog.js +3 -1
  26. package/dist/sessions/codec-messages.js +122 -0
  27. package/dist/sessions/codec-transcript.js +93 -0
  28. package/dist/sessions/codec-values.js +52 -0
  29. package/dist/sessions/codec.js +4 -257
  30. package/dist/sessions/files.js +158 -0
  31. package/dist/sessions/load.js +90 -0
  32. package/dist/sessions/snapshot.js +33 -0
  33. package/dist/sessions/store.js +42 -461
  34. package/dist/settings-command.js +10 -5
  35. package/dist/settings.js +16 -15
  36. package/dist/start.js +1 -2
  37. package/dist/tools/file-read.js +192 -0
  38. package/dist/tools/file-summary.js +9 -0
  39. package/dist/tools/{fs.js → file-write.js} +5 -193
  40. package/dist/tools/glob.js +107 -0
  41. package/dist/tools/index.js +2 -1
  42. package/dist/tools/search.js +1 -105
  43. package/dist/tui/app-workflows.js +8 -361
  44. package/dist/tui/approve.js +5 -3
  45. package/dist/tui/blocks.js +8 -7
  46. package/dist/tui/command-workflow.js +106 -0
  47. package/dist/tui/components/command-menu.js +7 -10
  48. package/dist/tui/components/menu.js +74 -43
  49. package/dist/tui/components/messages.js +16 -9
  50. package/dist/tui/components/tool-evidence.js +107 -0
  51. package/dist/tui/components/tool-motion.js +32 -0
  52. package/dist/tui/components/tool.js +48 -202
  53. package/dist/tui/help.js +1 -1
  54. package/dist/tui/picker-layout.js +40 -0
  55. package/dist/tui/picker.js +7 -71
  56. package/dist/tui/tool-details.js +135 -0
  57. package/dist/tui/transcript-grammar.js +8 -1
  58. package/dist/tui/transcript-view.js +26 -112
  59. package/dist/tui/turn-workflow.js +264 -0
  60. package/dist/tui/turn.js +7 -140
  61. package/dist/tui/workflow-types.js +2 -0
  62. package/package.json +12 -12
  63. package/dist/ollama-settings-command.js +0 -74
  64. package/dist/tui/motion.js +0 -32
@@ -1,12 +1,57 @@
1
- // One visual row grammar for autocomplete and every interactive selector.
2
- import { hasColor, plainLen, row } from "../../ui/render.js";
3
- import { elide } from "../../ui/width.js";
1
+ // Ribbon rows and a stable detail area shared by completion and selectors.
2
+ import { row } from "../../ui/render.js";
3
+ import { terminalText } from "../../ui/terminal-text.js";
4
+ import { elide, textWidth, wrapText } from "../../ui/width.js";
4
5
  export function renderMenuRows(entries, width, pal) {
6
+ return entries.map((entry) => {
7
+ const right = summary(entry, width);
8
+ return row(width, [
9
+ { text: entry.selected ? "● " : " ", fg: pal.focus },
10
+ { text: entry.label, fg: entry.selected ? pal.ink.bright : pal.ink.muted, bold: entry.selected },
11
+ ], right === "" ? [] : [{ text: right, fg: entry.selected ? pal.focus : pal.ink.dim }], entry.selected ? pal.surface.subtle : undefined);
12
+ });
13
+ }
14
+ /** A missing palette measures the window without producing styled rows. */
15
+ export function renderMenu(entries, width, pal, options) {
16
+ const room = Math.max(0, options.maxRows);
17
+ if (room === 0)
18
+ return { rows: [], first: 0, last: 0 };
19
+ const detailLimit = Math.min(2, room - 1);
20
+ let detailRows = 0;
21
+ // Measure every possible selection to keep the dock stable without reserving
22
+ // a second row when all details fit on one line at this width.
23
+ for (const entry of entries) {
24
+ detailRows = Math.max(detailRows, detailLines(entry, width, detailLimit).length);
25
+ if (detailRows === detailLimit)
26
+ break;
27
+ }
28
+ const visible = Math.min(options.visible ?? 6, room - detailRows);
29
+ const at = Math.max(0, entries.findIndex((entry) => entry.selected));
30
+ const { first, last } = menuWindow(entries.length, at, visible);
31
+ if (pal === undefined)
32
+ return { rows: [], first, last };
5
33
  if (entries.length === 0)
34
+ return {
35
+ rows: [row(width, [{ text: "No matches. Change the filter.", fg: pal.ink.muted }])], first, last,
36
+ };
37
+ const active = entries.find((entry) => entry.selected);
38
+ const lines = detailLines(active, width, detailRows);
39
+ return {
40
+ rows: [
41
+ ...renderMenuRows(entries.slice(first, last), width, pal),
42
+ ...Array.from({ length: detailRows }, (_, index) => lines[index] === undefined ? ""
43
+ : row(width, [{ text: " " + lines[index], fg: pal.ink.muted }])),
44
+ ], first, last,
45
+ };
46
+ }
47
+ function detailLines(entry, width, maxRows) {
48
+ if (entry === undefined || maxRows <= 0)
6
49
  return [];
7
- const widest = Math.max(...entries.map(primaryWidth));
8
- const labelWidth = Math.min(42, Math.max(12, widest + 2));
9
- return entries.map((entry) => renderEntry(entry, labelWidth, width, pal));
50
+ // Complete identity and values take priority over explanatory copy.
51
+ const room = Math.max(1, width - 2);
52
+ const lines = menuText(clippedParts(entry, width).join(" · "), room, maxRows);
53
+ lines.push(...menuText(entry.description ?? "", room, maxRows - lines.length));
54
+ return lines;
10
55
  }
11
56
  export function menuWindow(length, selected, visible) {
12
57
  const count = Math.max(0, length);
@@ -15,42 +60,28 @@ export function menuWindow(length, selected, visible) {
15
60
  const first = Math.max(0, Math.min(at - Math.floor(room / 2), count - room));
16
61
  return { first, last: Math.min(count, first + room) };
17
62
  }
18
- function renderEntry(entry, labelWidth, width, pal) {
19
- // Colour terminals spend focus on the active label instead of painting a
20
- // full-width band. Monochrome has no colour, so it alone reserves a fixed
21
- // arrow column to keep selection visible without shifting peer rows.
22
- const monochrome = !hasColor();
23
- const selectedMark = monochrome ? (entry.selected ? "→ " : " ") : "";
24
- const fg = entry.selected ? pal.focus : pal.ink.fg;
25
- const primary = [
26
- { text: selectedMark, fg },
27
- { text: entry.label, fg, bold: entry.selected || undefined },
63
+ /** Wrap untrusted menu copy, marking a bounded final row when it omits text. */
64
+ export function menuText(text, width, maxRows) {
65
+ if (text === "" || maxRows <= 0)
66
+ return [];
67
+ const lines = wrapText(terminalText(text), Math.max(1, width));
68
+ return lines.slice(0, maxRows).map((line, index) => index === maxRows - 1 && lines.length > maxRows ? elide(line + " …", width) : line);
69
+ }
70
+ function fullSummary(entry) {
71
+ const value = entry.value === undefined ? "" : entry.selected && entry.adjustable ? "‹ " + entry.value + " ›" : entry.value;
72
+ return [entry.hint, value].filter(Boolean).join(" · ");
73
+ }
74
+ function summary(entry, width) {
75
+ return elide(terminalText(fullSummary(entry)), Math.max(1, Math.floor(width * 0.42)));
76
+ }
77
+ function clippedParts(entry, width) {
78
+ // Reserve the stepper's width for every peer, so moving selection cannot
79
+ // make the shared detail area appear or disappear.
80
+ const stable = entry.adjustable ? { ...entry, selected: true } : entry;
81
+ const right = summary(stable, width);
82
+ const leftRoom = Math.max(0, width - 2 - (right === "" ? 0 : textWidth(right) + 1));
83
+ return [
84
+ ...(textWidth(terminalText(entry.label)) > leftRoom ? [entry.label] : []),
85
+ ...(right !== terminalText(fullSummary(stable)) ? [fullSummary(stable)] : []),
28
86
  ];
29
- if (width > 40 && entry.description !== undefined) {
30
- const gap = Math.max(2, labelWidth - primaryWidth(entry));
31
- primary.push({
32
- text: `${" ".repeat(gap)}${entry.description}`,
33
- fg: entry.selected ? pal.ink.bright : pal.ink.muted,
34
- });
35
- }
36
- const value = entry.value === undefined
37
- ? undefined
38
- : entry.selected && entry.adjustable === true
39
- ? `‹ ${entry.value} ›`
40
- : entry.value;
41
- const summary = [entry.hint, value]
42
- .filter((part) => part !== undefined)
43
- .join(" · ");
44
- const rightColor = entry.selected ? pal.focus : pal.ink.muted;
45
- const right = summary === "" || (width <= 40 && entry.value === undefined)
46
- ? []
47
- : [{
48
- text: elide(summary, Math.max(1, Math.floor(entry.value === undefined ? width / 4 : width * 0.45))),
49
- fg: rightColor,
50
- bold: entry.selected || undefined,
51
- }];
52
- return row(width, primary, right);
53
- }
54
- function primaryWidth(entry) {
55
- return plainLen([{ text: entry.label }]);
56
87
  }
@@ -1,25 +1,32 @@
1
1
  import { trailingText } from "../../text-boundary.js";
2
- import { blank, row } from "../../ui/render.js";
2
+ import { blank, hasColor, row } from "../../ui/render.js";
3
3
  import { markdown } from "../../ui/markdown.js";
4
+ import { terminalText } from "../../ui/terminal-text.js";
5
+ import { splitByCells } from "../../ui/width.js";
4
6
  import { transcriptLead, transcriptWidth } from "../transcript-grammar.js";
5
7
  export const REASONING_PREVIEW_ROWS = 3;
6
8
  const MIN_REASONING_PREVIEW_CHARS = 4_096;
7
9
  const REASONING_PREVIEW_OVERSCAN = 12;
8
10
  export function renderUser(block, width, pal) {
9
11
  const inner = transcriptWidth(width);
10
- const content = markdown(block.text, inner, pal, inner);
12
+ const content = terminalText(block.text, { multiline: true }).split("\n")
13
+ .flatMap((line) => splitByCells(line, inner));
11
14
  return [
12
15
  "",
13
- blank(width, pal.surface.subtle),
14
- ...content.map((line, index) => row(width, [
15
- ...transcriptLead(width, index === 0
16
- ? { text: "❯", fg: pal.accent, bold: true }
17
- : undefined),
18
- ...line.segs,
16
+ userEdge(width, pal, "▄"),
17
+ ...content.map((line) => row(width, [
18
+ ...transcriptLead(width),
19
+ { text: line.text, fg: pal.ink.fg },
19
20
  ], [], pal.surface.subtle)),
20
- blank(width, pal.surface.subtle),
21
+ userEdge(width, pal, "▀"),
21
22
  ];
22
23
  }
24
+ /** Half-cell colour keeps the surface light without moving surrounding text. */
25
+ function userEdge(width, pal, half) {
26
+ if (!hasColor())
27
+ return blank(width, pal.surface.subtle);
28
+ return row(width, [{ text: half.repeat(width), fg: pal.surface.subtle }]);
29
+ }
23
30
  export function renderAnswer(block, width, pal) {
24
31
  return [
25
32
  "",
@@ -0,0 +1,107 @@
1
+ // Selection and rendering of retained tool evidence. Never mutates its source.
2
+ import { row } from "../../ui/render.js";
3
+ import { graphemeCeiling, graphemeFloor } from "../../text-boundary.js";
4
+ import { transcriptLead } from "../transcript-grammar.js";
5
+ // Native exception headings may include a Node error code before the message.
6
+ const ERROR_DIAGNOSTIC = /^\s*\w*Error(?:\s+\[[^\]]+\])?\s*:|\berror\b\s*:|AssertionError/i;
7
+ export function toolEvidence(block) {
8
+ const all = block.body ?? [];
9
+ const changed = [];
10
+ let added = 0, removed = 0, regions = 0, context = 3;
11
+ for (const detail of all) {
12
+ if (detail.kind === "keep") {
13
+ context++;
14
+ continue;
15
+ }
16
+ if (detail.kind !== "add" && detail.kind !== "del") {
17
+ context = 3;
18
+ continue;
19
+ }
20
+ if (context >= 3)
21
+ regions++;
22
+ context = 0;
23
+ if (detail.kind === "add")
24
+ added++;
25
+ else
26
+ removed++;
27
+ changed.push(detail);
28
+ }
29
+ const counts = changed.length === 0 ? "" : `+${added} −${removed}`;
30
+ const summary = counts === "" ? "" : `${counts} · ${regions} ${regions === 1 ? "region" : "regions"}`;
31
+ if (all.length === 0)
32
+ return { details: [], counts, summary, note: "" };
33
+ if (block.expanded)
34
+ return { details: all, counts, summary, note: "ctrl+o collapse" };
35
+ if (changed.length > 0)
36
+ return {
37
+ details: changed.length <= 6 ? changed : [
38
+ ...changed.slice(0, 3),
39
+ { kind: "gap", text: `… ${changed.length - 6} more changed ${changed.length === 7 ? "line" : "lines"}` },
40
+ ...changed.slice(-3),
41
+ ], counts, summary, note: "ctrl+o full source",
42
+ };
43
+ const diagnostic = block.tone === "fail"
44
+ ? all.find((detail) => ERROR_DIAGNOSTIC.test(detail.text))
45
+ ?? all.find((detail) => /^not ok\b|^\s*[×✖]/i.test(detail.text)) : undefined;
46
+ let details = all.slice(-4);
47
+ if (diagnostic !== undefined && !details.includes(diagnostic))
48
+ details = [diagnostic, ...all.slice(-3)];
49
+ const hidden = all.length - details.length;
50
+ return {
51
+ details, counts, summary,
52
+ note: hidden > 0 ? `${hidden} ${diagnostic === undefined ? "earlier" : "other"} ${hidden === 1 ? "line" : "lines"} · ctrl+o`
53
+ : `${all.length} ${all.length === 1 ? "line" : "lines"}`,
54
+ };
55
+ }
56
+ export function renderDetail(detail, tone, width, pal, railInk = pal.rule) {
57
+ const rail = transcriptLead(width, { text: "│", fg: railInk });
58
+ if (detail.kind === "out") {
59
+ const base = tone === "fail" || failureLine(detail.text) ? pal.ink.removed : pal.ink.muted;
60
+ return row(width, [
61
+ ...rail,
62
+ { text: detail.text === "" ? " " : detail.text, fg: base },
63
+ ]);
64
+ }
65
+ if (detail.kind === "gap") {
66
+ return row(width, [
67
+ ...rail,
68
+ { text: detail.text, fg: pal.ink.dim, italic: true },
69
+ ]);
70
+ }
71
+ const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
72
+ const sign = detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " ";
73
+ const fg = detail.kind === "add"
74
+ ? pal.ink.added
75
+ : detail.kind === "del"
76
+ ? pal.ink.removed
77
+ : pal.ink.dim;
78
+ const tint = detail.kind === "add"
79
+ ? pal.surface.added
80
+ : detail.kind === "del"
81
+ ? pal.surface.removed
82
+ : undefined;
83
+ return row(width, [
84
+ ...rail,
85
+ { text: sign, fg, bold: detail.kind !== "keep" },
86
+ { text: `${String(number ?? "").padStart(4)} `, fg: pal.ink.dim },
87
+ ...emphasized(detail.text, detail.emphasis, fg, tint),
88
+ ]);
89
+ }
90
+ function emphasized(text, emphasis, fg, bg) {
91
+ if (emphasis === undefined || emphasis.length <= 0)
92
+ return [{ text, fg }];
93
+ const requestedStart = Math.max(0, Math.min(text.length, emphasis.start));
94
+ const requestedEnd = Math.max(requestedStart, Math.min(text.length, requestedStart + emphasis.length));
95
+ const start = graphemeFloor(text, requestedStart);
96
+ const end = graphemeCeiling(text, requestedEnd);
97
+ if (start === end)
98
+ return [{ text, fg }];
99
+ return [
100
+ ...(start === 0 ? [] : [{ text: text.slice(0, start), fg }]),
101
+ { text: text.slice(start, end), fg, bg, bold: true },
102
+ ...(end === text.length ? [] : [{ text: text.slice(end), fg }]),
103
+ ];
104
+ }
105
+ function failureLine(line) {
106
+ return line.startsWith("✖") || line.startsWith("×") || line.includes("AssertionError") || /\bfailed\b/i.test(line);
107
+ }
@@ -0,0 +1,32 @@
1
+ // One travelling light on the execution connector; evidence never animates.
2
+ import { hasColor } from "../../ui/render.js";
3
+ export function runningTool(block) {
4
+ return block.kind === "tool" && block.tone === "pending" &&
5
+ block.startedAt !== undefined && block.right === "running";
6
+ }
7
+ export function stateInk(block, pal) {
8
+ if (block.tone === "fail")
9
+ return pal.ink.removed;
10
+ if (block.tone === "deny")
11
+ return pal.ink.attention;
12
+ if (block.tone === "ok")
13
+ return pal.ink.added;
14
+ return runningTool(block) ? pal.accent : pal.ink.dim;
15
+ }
16
+ export function stateMark(block) {
17
+ return block.tone === "ok" ? "✓" : block.tone === "fail" ? "×" : block.tone === "deny" ? "!" : "○";
18
+ }
19
+ export function connectorInk(block, pal, context, position, length) {
20
+ if (context.reducedMotion === true || block.expanded === true || !hasColor() || !runningTool(block))
21
+ return pal.rule;
22
+ const elapsed = Math.max(0, (context.now ?? Date.now()) - block.startedAt);
23
+ const head = (elapsed % 2000) / 2000 * (length + 3) - 1.5;
24
+ const amount = Math.max(0, 1 - Math.abs(position - head) / 1.8);
25
+ const channel = (index) => Math.round(pal.rule[index] + (pal.accent[index] - pal.rule[index]) * amount);
26
+ return [channel(0), channel(1), channel(2)];
27
+ }
28
+ export function connector(text, block, pal, context, position, length) {
29
+ return Array.from(text, (char, index) => ({
30
+ text: char, fg: connectorInk(block, pal, context, position + index / 3, length),
31
+ }));
32
+ }
@@ -1,210 +1,56 @@
1
- // A compact execution trace: state and identity on one semantic rail,
2
- // bounded evidence below, and motion that never enters durable state.
3
- import { fitSegs, hasColor, plainLen, row } from "../../ui/render.js";
4
- import { graphemeCeiling, graphemeFloor } from "../../text-boundary.js";
1
+ // Target-first execution records, connected to bounded, expandable evidence.
2
+ import { plainLen, row } from "../../ui/render.js";
5
3
  import { toolDuration } from "../../duration.js";
6
- import { breathe, easeInOut, easeOut, interval, mix, TOOL_BIRTH_MS, TOOL_LEADER_MAX_MS, TOOL_ROW_ARRIVAL_MS, } from "../motion.js";
7
- import { transcriptLead } from "../transcript-grammar.js";
8
- const OUTPUT_ROWS = 8;
9
- const LIVE_OUTPUT_ROWS = 6;
10
- const DIFF_ROWS = 15;
11
- const TOOL_NAME_COLS = 12;
12
- const TOOL_COLUMNS_AT = 64;
13
- const SPINNER_MS = 80;
14
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
+ import { toolEvidence, renderDetail } from "./tool-evidence.js";
5
+ import { connector, connectorInk, runningTool, stateInk, stateMark } from "./tool-motion.js";
15
6
  export function renderTool(block, width, pal, context = {}) {
16
7
  const now = context.now ?? Date.now();
17
- const shown = visibleDetails(block);
18
- const ink = stateInk(block, pal, context, now);
19
- const nameInk = birthInk(pal.ink.bright, pal.ink.dim, context, now);
8
+ const clock = { ...context, now };
9
+ const evidence = toolEvidence(block);
10
+ const active = runningTool(block);
11
+ const elapsed = active ? toolDuration(Math.max(0, now - block.startedAt), true)
12
+ : block.durationMs === undefined ? "" : toolDuration(block.durationMs);
13
+ const mark = stateMark(block);
14
+ // The separate change summary already carries these exact counts.
15
+ const repeatedCount = evidence.counts !== "" && block.right.startsWith(`${evidence.counts} · `);
16
+ const outcome = repeatedCount ? block.right.slice(evidence.counts.length + 3) : block.right;
17
+ const result = [`${mark}${outcome === "" ? "" : ` ${outcome}`}`, elapsed].filter(Boolean).join(" · ");
18
+ const hasEvidence = evidence.details.length > 0;
19
+ const name = { text: block.name, fg: pal.ink.dim };
20
+ const right = [{ text: result, fg: stateInk(block, pal) }];
21
+ const separateStatus = plainLen([name, ...right]) + (hasEvidence ? 2 : 3) + 3 > width;
22
+ const summaryAt = separateStatus ? 3 : 2;
23
+ const detailStart = summaryAt + (evidence.summary === "" ? 0 : 1);
24
+ const length = hasEvidence ? detailStart + evidence.details.length + 1 : summaryAt;
20
25
  const left = [
21
- ...transcriptLead(width, { text: stateGlyph(block, context, now), fg: ink, bold: true }),
22
- { text: toolName(block, width), fg: nameInk, bold: true },
23
- ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
26
+ ...connector(hasEvidence || separateStatus ? "│ " : "└─ ", block, pal, clock, 1, length),
27
+ name,
24
28
  ];
25
- const right = resultSegments(block, pal, context, now);
26
- const leader = movingLeader(width, left, right, pal, context, now);
27
- return [
29
+ const rows = [
28
30
  ...(context.continues === true ? [] : [""]),
29
- row(width, leader === undefined ? left : [...left, leader], right),
30
- ...shown.map(({ detail, sourceIndex }) => renderDetail(detail, block.tone, width, pal, context.reducedMotion === true ? undefined : context.motion?.rowsAt[sourceIndex ?? -1], now)),
31
+ row(width, [
32
+ ...connector("┌ ", block, pal, clock, 0, length),
33
+ { text: block.target || block.name, fg: pal.technical },
34
+ ]),
35
+ separateStatus ? row(width, left)
36
+ : row(Math.min(width, plainLen([...left, ...right]) + 3), left, right),
31
37
  ];
32
- }
33
- function visibleDetails(block) {
34
- const all = block.body ?? [];
35
- if (block.expanded === true || all.length === 0) {
36
- return all.map((detail, sourceIndex) => ({ detail, sourceIndex }));
37
- }
38
- if (all.every((detail) => detail.kind === "out")) {
39
- const limit = block.tone === "pending" ? LIVE_OUTPUT_ROWS : OUTPUT_ROWS;
40
- if (all.length <= limit)
41
- return all.map((detail, sourceIndex) => ({ detail, sourceIndex }));
42
- const hidden = all.length - limit;
43
- const note = block.tone === "pending"
44
- ? `… ${all.length} lines so far`
45
- : `… ${hidden} earlier lines · ctrl+o expand`;
46
- return [
47
- { detail: { kind: "gap", text: note } },
48
- ...all.slice(-limit).map((detail, index) => ({
49
- detail,
50
- sourceIndex: all.length - limit + index,
51
- })),
52
- ];
53
- }
54
- // A compact transcript audits what changed rather than repeating unchanged
55
- // source. One budget covers writes and edits, with both ends retained.
56
- const changed = all
57
- .map((detail, sourceIndex) => ({ detail, sourceIndex }))
58
- .filter(({ detail }) => detail.kind === "add" || detail.kind === "del");
59
- if (changed.length <= DIFF_ROWS)
60
- return changed;
61
- const leading = Math.ceil(DIFF_ROWS / 2);
62
- const trailing = DIFF_ROWS - leading;
63
- const hidden = changed.length - DIFF_ROWS;
64
- return [
65
- ...changed.slice(0, leading),
66
- {
67
- detail: {
68
- kind: "gap",
69
- text: `… ${hidden} more changed ${hidden === 1 ? "line" : "lines"} · ctrl+o expand`,
70
- },
71
- },
72
- ...changed.slice(-trailing),
73
- ];
74
- }
75
- function renderDetail(detail, tone, width, pal, arrivedAt, now) {
76
- const rail = transcriptLead(width, { text: "│", fg: pal.rule });
77
- if (detail.kind === "out") {
78
- const base = tone === "fail" || failureLine(detail.text) ? pal.ink.removed : pal.ink.muted;
79
- return row(width, [
80
- ...rail,
81
- { text: detail.text === "" ? " " : detail.text, fg: arrivalInk(base, pal, arrivedAt, now) },
82
- ]);
83
- }
84
- if (detail.kind === "gap") {
85
- return row(width, [
86
- ...rail,
87
- { text: detail.text, fg: arrivalInk(pal.ink.dim, pal, arrivedAt, now), italic: true },
88
- ]);
89
- }
90
- const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
91
- const sign = detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " ";
92
- const base = detail.kind === "add"
93
- ? pal.ink.added
94
- : detail.kind === "del"
95
- ? pal.ink.removed
96
- : pal.ink.dim;
97
- const fg = arrivalInk(base, pal, arrivedAt, now);
98
- const tint = detail.kind === "add"
99
- ? pal.surface.added
100
- : detail.kind === "del"
101
- ? pal.surface.removed
102
- : undefined;
103
- return row(width, [
104
- ...rail,
105
- { text: sign, fg, bold: detail.kind !== "keep" },
106
- { text: `${String(number ?? "").padStart(4)} `, fg: pal.ink.dim },
107
- ...emphasized(detail.text, detail.emphasis, fg, tint),
108
- ]);
109
- }
110
- function emphasized(text, emphasis, fg, bg) {
111
- if (emphasis === undefined || emphasis.length <= 0)
112
- return [{ text, fg }];
113
- const requestedStart = Math.max(0, Math.min(text.length, emphasis.start));
114
- const requestedEnd = Math.max(requestedStart, Math.min(text.length, requestedStart + emphasis.length));
115
- const start = graphemeFloor(text, requestedStart);
116
- const end = graphemeCeiling(text, requestedEnd);
117
- if (start === end)
118
- return [{ text, fg }];
119
- return [
120
- ...(start === 0 ? [] : [{ text: text.slice(0, start), fg }]),
121
- { text: text.slice(start, end), fg, bg, bold: true },
122
- ...(end === text.length ? [] : [{ text: text.slice(end), fg }]),
123
- ];
124
- }
125
- function toolName(block, width) {
126
- if (width < TOOL_COLUMNS_AT || block.target === "")
127
- return block.name;
128
- return block.name.padEnd(TOOL_NAME_COLS);
129
- }
130
- function stateGlyph(block, context, now) {
131
- if (block.tone === "pending") {
132
- if (block.startedAt === undefined || block.right !== "running")
133
- return "○";
134
- if (context.reducedMotion === true)
135
- return "○";
136
- return SPINNER[Math.floor(now / SPINNER_MS) % SPINNER.length] ?? "○";
137
- }
138
- if (block.tone === "fail")
139
- return hasColor() ? "●" : "×";
140
- if (block.tone === "deny")
141
- return "○";
142
- return hasColor() ? "●" : "✓";
143
- }
144
- function resultSegments(block, pal, context, now) {
145
- const status = block.right;
146
- const duration = liveDuration(block, now);
147
- if (status === "" && duration === "")
148
- return [];
149
- const ink = resultInk(block, pal, context, now);
150
- return [
151
- ...(status === "" ? [] : [{ text: status, fg: ink }]),
152
- ...(duration === "" ? [] : [{ text: `${status === "" ? "" : " · "}${duration}`, fg: pal.ink.dim }]),
153
- ];
154
- }
155
- function liveDuration(block, now) {
156
- if (block.tone === "pending" && block.startedAt !== undefined && block.right === "running") {
157
- return toolDuration(Math.max(0, now - block.startedAt), true);
158
- }
159
- return block.durationMs === undefined ? "" : toolDuration(block.durationMs);
160
- }
161
- function stateInk(block, pal, context, now) {
162
- if (block.tone === "fail")
163
- return pal.ink.removed;
164
- if (block.tone === "deny")
165
- return pal.ink.attention;
166
- if (block.tone === "pending") {
167
- if (block.startedAt === undefined || context.reducedMotion === true)
168
- return pal.ink.attention;
169
- return mix(pal.ink.attention, pal.accent, breathe(now));
170
- }
171
- return pal.ink.added;
172
- }
173
- function resultInk(block, pal, context, now) {
174
- return block.tone === "ok" ? pal.ink.muted : stateInk(block, pal, context, now);
175
- }
176
- function birthInk(final, initial, context, now) {
177
- if (context.reducedMotion === true || context.motion === undefined)
178
- return final;
179
- return mix(initial, final, easeOut(interval(now, context.motion.bornAt, TOOL_BIRTH_MS)));
180
- }
181
- function arrivalInk(base, pal, arrivedAt, now) {
182
- if (arrivedAt === undefined)
183
- return base;
184
- return mix(pal.ink.bright, base, easeOut(interval(now, arrivedAt, TOOL_ROW_ARRIVAL_MS)));
185
- }
186
- function movingLeader(width, left, right, pal, context, now) {
187
- if (context.reducedMotion === true || context.motion === undefined || right.length === 0)
188
- return undefined;
189
- const progress = interval(now, context.motion.bornAt, TOOL_LEADER_MAX_MS);
190
- if (progress >= 1)
191
- return undefined;
192
- const fittedRight = fitSegs(right, width);
193
- const gap = width - plainLen(left) - plainLen(fittedRight) - 1;
194
- if (gap < 9)
195
- return undefined;
196
- const cells = Array.from({ length: gap }, () => " ");
197
- const travelCells = gap - 2;
198
- const trail = Math.min(7, travelCells);
199
- const travel = travelCells + trail;
200
- const head = Math.floor(easeInOut(progress) * travel) - trail;
201
- for (let offset = 0; offset < trail; offset++) {
202
- const at = head + offset;
203
- if (at >= 0 && at < travelCells)
204
- cells[at + 1] = "·";
205
- }
206
- return { text: cells.join(""), fg: pal.rule };
207
- }
208
- function failureLine(line) {
209
- return line.startsWith("✖") || line.startsWith("×") || line.includes("AssertionError") || /\bfailed\b/i.test(line);
38
+ if (separateStatus)
39
+ rows.push(row(width, [
40
+ ...connector(hasEvidence ? "│ " : "└─ ", block, pal, clock, 2, length),
41
+ ...right,
42
+ ]));
43
+ if (!hasEvidence)
44
+ return rows;
45
+ if (evidence.summary !== "")
46
+ rows.push(row(width, [
47
+ ...connector("│ ", block, pal, clock, summaryAt, length),
48
+ { text: evidence.summary, fg: pal.ink.muted },
49
+ ]));
50
+ rows.push(...evidence.details.map((detail, index) => renderDetail(detail, block.tone, width, pal, block.expanded ? pal.rule : connectorInk(block, pal, clock, detailStart + index, length))));
51
+ rows.push(row(width, [
52
+ ...connector("└─", block, pal, clock, length - 1, length),
53
+ { text: evidence.note === "" ? "" : ` ${evidence.note}`, fg: pal.ink.dim },
54
+ ]));
55
+ return rows;
210
56
  }
package/dist/tui/help.js CHANGED
@@ -11,7 +11,7 @@ const CONTROLS = [
11
11
  { key: "alt+enter", description: "insert a new line" },
12
12
  { key: "esc", description: "close UI or interrupt work" },
13
13
  { key: "ctrl+c", description: "interrupt work or exit" },
14
- { key: "ctrl+o", description: "toggle reasoning or tool details" },
14
+ { key: "ctrl+o", description: "expand/collapse reasoning or evidence" },
15
15
  { key: "wheel / pgup/dn", description: "scroll the transcript" },
16
16
  { key: "ctrl+l", description: "redraw the screen" },
17
17
  ];
@@ -0,0 +1,40 @@
1
+ // Bounded Ribbon composition; filtering and selection remain in picker.ts.
2
+ import { row } from "../ui/render.js";
3
+ import { menuText, renderMenu } from "./components/menu.js";
4
+ import { promptCursor, promptLine } from "./components/prompt.js";
5
+ export function layoutPicker(picker, found, width, pal, maxRows) {
6
+ const budget = Math.max(0, maxRows);
7
+ const queryRows = picker.searchable && budget >= 2 ? 1 : 0;
8
+ const titleRows = picker.title.length > 0 && budget - queryRows >= 2 ? 1 : 0;
9
+ const controlRows = budget - queryRows - titleRows >= 2 ? 1 : 0;
10
+ let contextRoom = Math.max(0, budget - queryRows - titleRows - controlRows - 1);
11
+ const target = menuText(picker.right ?? "", width, Math.min(2, contextRoom));
12
+ contextRoom -= target.length;
13
+ const note = menuText(picker.description ?? picker.footer ?? "", width, Math.min(2, contextRoom));
14
+ const prefix = titleRows + target.length + note.length + queryRows;
15
+ const menu = renderMenu(found.map(({ option, index }) => ({ ...option, selected: index === picker.index })), width, pal, { maxRows: budget - prefix - controlRows, visible: picker.visible ?? 6 });
16
+ const progress = found.length > menu.last - menu.first || picker.searchable
17
+ ? `${found.length === 0 ? "0" : `${menu.first + 1}–${menu.last}`} / ${found.length}` +
18
+ (found.length === picker.options.length ? "" : ` · ${picker.options.length} total`)
19
+ : "";
20
+ const query = picker.query ?? "";
21
+ const cursor = queryRows === 0 ? undefined : {
22
+ row: prefix - 1,
23
+ col: promptCursor(query, query.length, width, { right: progress }).col,
24
+ };
25
+ if (pal === undefined)
26
+ return { rows: [], cursor };
27
+ const controls = picker.controls ?? (picker.adjust
28
+ ? "↑↓ move · ←→ change · esc close" : "↑↓ move · enter select · esc close");
29
+ return {
30
+ rows: [
31
+ ...(titleRows === 0 ? [] : [row(width, picker.title, queryRows > 0 || progress === "" ? [] : [{ text: progress, fg: pal.ink.dim }])]),
32
+ ...[...target, ...note].map((text) => row(width, [{ text, fg: pal.ink.muted }])),
33
+ ...(queryRows === 0 ? [] : [promptLine(query, query.length, width, pal, {
34
+ placeholder: "type to filter", right: progress,
35
+ }).row]),
36
+ ...menu.rows,
37
+ ...(controlRows === 0 ? [] : [row(width, [{ text: controls, fg: pal.ink.dim }])]),
38
+ ], cursor,
39
+ };
40
+ }