@giovannijecha/jecode 0.3.2 → 0.5.0

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 (54) hide show
  1. package/README.md +63 -23
  2. package/dist/batch.js +22 -2
  3. package/dist/cli-info.js +3 -0
  4. package/dist/command-settings.js +19 -0
  5. package/dist/commands.js +11 -14
  6. package/dist/config.js +2 -0
  7. package/dist/controller.js +3 -0
  8. package/dist/conversation.js +208 -0
  9. package/dist/credential-commands.js +63 -80
  10. package/dist/credentials.js +7 -1
  11. package/dist/launch.js +19 -0
  12. package/dist/model-command.js +171 -0
  13. package/dist/permission-command.js +52 -53
  14. package/dist/provider-commands.js +71 -228
  15. package/dist/provider-errors.js +4 -3
  16. package/dist/provider-label.js +2 -2
  17. package/dist/providers/ollama.js +8 -3
  18. package/dist/sessions/codec.js +344 -0
  19. package/dist/sessions/lease.js +76 -0
  20. package/dist/sessions/runtime.js +73 -0
  21. package/dist/sessions/store.js +368 -0
  22. package/dist/settings-command.js +43 -98
  23. package/dist/start.js +67 -4
  24. package/dist/transcript-types.js +6 -0
  25. package/dist/tui/activity.js +3 -0
  26. package/dist/tui/app-input.js +9 -6
  27. package/dist/tui/app-workflows.js +37 -8
  28. package/dist/tui/app.js +72 -20
  29. package/dist/tui/components/composer.js +1 -1
  30. package/dist/tui/components/footer.js +1 -1
  31. package/dist/tui/components/menu.js +27 -15
  32. package/dist/tui/components/messages.js +1 -15
  33. package/dist/tui/components/prompt.js +2 -2
  34. package/dist/tui/components/status.js +5 -2
  35. package/dist/tui/components/tool.js +22 -5
  36. package/dist/tui/editor.js +54 -7
  37. package/dist/tui/feedback.js +5 -2
  38. package/dist/tui/field.js +1 -1
  39. package/dist/tui/help.js +4 -1
  40. package/dist/tui/input.js +4 -0
  41. package/dist/tui/keys.js +14 -3
  42. package/dist/tui/overlay.js +6 -0
  43. package/dist/tui/picker.js +26 -10
  44. package/dist/tui/resume.js +24 -0
  45. package/dist/tui/session-view.js +2 -2
  46. package/dist/tui/turn.js +2 -3
  47. package/dist/tui/view.js +1 -1
  48. package/dist/ui/diff.js +2 -0
  49. package/dist/ui/inline.js +2 -2
  50. package/dist/ui/markdown.js +7 -7
  51. package/dist/ui/render.js +2 -0
  52. package/dist/ui/theme.js +4 -4
  53. package/dist/usage.js +9 -0
  54. package/package.json +1 -1
package/dist/start.js CHANGED
@@ -3,12 +3,16 @@ import * as path from "node:path";
3
3
  import { runBatch } from "./batch.js";
4
4
  import { showCliInfo } from "./cli-info.js";
5
5
  import { loadConfig } from "./config.js";
6
+ import { ConversationTree } from "./conversation.js";
7
+ import { parseLaunch } from "./launch.js";
6
8
  import { systemPrompt } from "./prompt.js";
7
9
  import { configureProviders, selectProvider } from "./providers/index.js";
10
+ import { SessionPersistence } from "./sessions/runtime.js";
11
+ import { DurableSessionStore } from "./sessions/store.js";
8
12
  import { builtinTools } from "./tools/index.js";
9
13
  import { configureColor } from "./ui/render.js";
10
14
  import { STEEL } from "./ui/theme.js";
11
- import { emptyUsage } from "./usage.js";
15
+ import { emptyUsage, usageFromHistory } from "./usage.js";
12
16
  import { runApp } from "./tui/app.js";
13
17
  import { interactive } from "./tui/screen.js";
14
18
  export async function start(args = process.argv.slice(2), environment = {}) {
@@ -17,10 +21,17 @@ export async function start(args = process.argv.slice(2), environment = {}) {
17
21
  const write = environment.write ?? ((text) => process.stdout.write(text));
18
22
  if (await showCliInfo(args, applicationRoot, write))
19
23
  return;
20
- const config = loadConfig(args);
24
+ const launch = parseLaunch(args);
25
+ const config = loadConfig(launch.configArgs);
21
26
  configureProviders(config);
22
27
  const provider = selectProvider(config.providerId);
23
28
  const hasScreen = environment.interactive?.() ?? interactive();
29
+ if (launch.kind === "resume" && !hasScreen) {
30
+ throw new Error("resume needs an interactive terminal");
31
+ }
32
+ if (launch.kind === "resume" && config.ephemeral) {
33
+ throw new Error("--ephemeral cannot be combined with resume");
34
+ }
24
35
  // A provider whose catalogue is not fixed has no sensible default model.
25
36
  // The TUI can ask; a pipe cannot, so batch mode still requires one up front.
26
37
  const model = config.model === "" ? provider.defaultModel : config.model;
@@ -35,13 +46,65 @@ export async function start(args = process.argv.slice(2), environment = {}) {
35
46
  palette: STEEL,
36
47
  tools: builtinTools(),
37
48
  system: systemPrompt(config),
38
- history: [],
49
+ conversation: ConversationTree.empty(),
39
50
  usage: emptyUsage(),
40
51
  };
41
52
  if (hasScreen) {
42
- await (environment.runInteractive ?? runApp)(session, transcriptRoot);
53
+ if (!config.ephemeral) {
54
+ const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
55
+ if (launch.kind === "resume") {
56
+ const candidates = await SessionPersistence.candidates(store);
57
+ if (candidates.length === 0)
58
+ throw new Error("no resumable sessions found for this workspace");
59
+ const open = async (id) => {
60
+ const resumed = await SessionPersistence.resume(store, id);
61
+ try {
62
+ applyResumedSession(session, resumed.conversation, resumed.persistence);
63
+ }
64
+ catch (error) {
65
+ await resumed.persistence.close();
66
+ throw error;
67
+ }
68
+ };
69
+ if (launch.latest)
70
+ await open(candidates[0].id);
71
+ else
72
+ session.resume = { candidates, open };
73
+ }
74
+ else {
75
+ session.persistence = SessionPersistence.fresh(store);
76
+ }
77
+ }
78
+ try {
79
+ await (environment.runInteractive ?? runApp)(session, transcriptRoot);
80
+ }
81
+ finally {
82
+ await session.persistence?.close();
83
+ }
43
84
  }
44
85
  else {
45
86
  await (environment.runNonInteractive ?? runBatch)(session);
46
87
  }
47
88
  }
89
+ function applyResumedSession(session, conversation, persistence) {
90
+ const identity = conversation.activeNode?.identity;
91
+ if (identity === undefined)
92
+ throw new Error("resumed session has no active turn");
93
+ const provider = selectProvider(identity.providerId);
94
+ const config = {
95
+ ...session.config,
96
+ providerId: identity.providerId,
97
+ model: identity.model,
98
+ effort: identity.effort,
99
+ };
100
+ const system = systemPrompt(config);
101
+ const usage = usageFromHistory(conversation.history);
102
+ session.config = config;
103
+ session.provider = provider;
104
+ session.model = identity.model;
105
+ session.system = system;
106
+ session.conversation = conversation;
107
+ session.usage = usage;
108
+ session.persistence = persistence;
109
+ session.resume = undefined;
110
+ }
@@ -0,0 +1,6 @@
1
+ // Provider-neutral transcript vocabulary.
2
+ //
3
+ // The conversation domain stores these settled semantic blocks so a resumed
4
+ // session can rebuild the same screen without persisting terminal escape
5
+ // sequences or renderer state.
6
+ export {};
@@ -9,3 +9,6 @@ export function elapsed(activity, now = Date.now()) {
9
9
  const minutes = Math.floor(seconds / 60);
10
10
  return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
11
11
  }
12
+ export function activityStatus(activity, label = activity.label, now = Date.now()) {
13
+ return `${label} · ${elapsed(activity, now)}`;
14
+ }
@@ -21,6 +21,15 @@ export function appInput(options) {
21
21
  }
22
22
  if (state.feedback !== undefined)
23
23
  feedback.dismiss();
24
+ // Detail expansion remains available while an approval is open. A large
25
+ // diff may be compacted, but the user must be able to inspect it before
26
+ // answering the permission prompt.
27
+ if (key.ctrl && key.name === "o") {
28
+ const changed = toggleDetails(state.blocks);
29
+ if (changed !== undefined)
30
+ options.transcriptChanged(changed);
31
+ return;
32
+ }
24
33
  if (state.open !== undefined) {
25
34
  const outcome = overlay.handle(state.open, key);
26
35
  state.open = outcome.open;
@@ -95,12 +104,6 @@ export function appInput(options) {
95
104
  options.invalidate();
96
105
  return;
97
106
  }
98
- if (key.ctrl && key.name === "o") {
99
- const changed = toggleDetails(state.blocks);
100
- if (changed !== undefined)
101
- options.transcriptChanged(changed);
102
- return;
103
- }
104
107
  const edited = applyKey(state.editor, key);
105
108
  if (edited !== undefined) {
106
109
  state.editor = edited;
@@ -39,7 +39,8 @@ export function appWorkflows(options) {
39
39
  state.status = said ?? activity.label;
40
40
  options.render();
41
41
  },
42
- reset: () => {
42
+ reset: async () => {
43
+ await session.persistence?.reset();
43
44
  state.blocks.splice(0);
44
45
  state.past.length = 0;
45
46
  permissions.reset();
@@ -59,11 +60,16 @@ export function appWorkflows(options) {
59
60
  state.closeWhenIdle = true;
60
61
  }
61
62
  catch (error) {
62
- feedback.show({
63
- text: activity.control.signal.aborted ? "interrupted" : error.message,
64
- tone: activity.control.signal.aborted ? "warn" : "error",
65
- timeoutMs: activity.control.signal.aborted ? 4_200 : 6_000,
66
- });
63
+ // Command cancellation is already visible through the dock closing and
64
+ // the activity ending. Keep it silent instead of replacing the footer
65
+ // with a redundant warning.
66
+ if (!activity.control.signal.aborted) {
67
+ feedback.show({
68
+ text: error.message,
69
+ tone: "error",
70
+ timeoutMs: 6_000,
71
+ });
72
+ }
67
73
  }
68
74
  finally {
69
75
  options.finishActivity(activity);
@@ -73,8 +79,14 @@ export function appWorkflows(options) {
73
79
  const activity = options.startActivity("turn", WAITING);
74
80
  if (activity === undefined)
75
81
  return;
82
+ const parentId = session.conversation.activeNodeId;
83
+ const history = session.conversation.history;
84
+ const historyStart = history.length;
85
+ const blockStart = state.blocks.length;
86
+ const createdAt = new Date().toISOString();
87
+ let nodeId;
76
88
  options.emit({ kind: "user", text });
77
- session.history.push({ role: "user", content: [{ kind: "text", text }] });
89
+ history.push({ role: "user", content: [{ kind: "text", text }] });
78
90
  const events = transcribe({
79
91
  emit: options.emit,
80
92
  render: options.render,
@@ -90,9 +102,26 @@ export function appWorkflows(options) {
90
102
  },
91
103
  usage: (usage) => recordUsage(session.usage, usage),
92
104
  });
105
+ events.onCheckpoint = async (checkpoint, settlement) => {
106
+ const next = session.conversation.commit({
107
+ ...(nodeId === undefined ? {} : { nodeId }),
108
+ parentId,
109
+ createdAt,
110
+ identity: {
111
+ providerId: session.provider.id,
112
+ model: session.model,
113
+ effort: session.config.effort,
114
+ },
115
+ messages: checkpoint.slice(historyStart),
116
+ blocks: state.blocks.slice(blockStart),
117
+ }, settlement);
118
+ await session.persistence?.checkpoint(next);
119
+ session.conversation = next;
120
+ nodeId = next.activeNodeId;
121
+ };
93
122
  let finishReason;
94
123
  try {
95
- await runTurn(session.history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal);
124
+ await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal);
96
125
  }
97
126
  catch (error) {
98
127
  const interrupted = activity.control.signal.aborted;
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 } from "./activity.js";
6
+ import { activityStatus, 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";
@@ -19,6 +19,7 @@ import { appState } from "./app-state.js";
19
19
  import { appInput } from "./app-input.js";
20
20
  import { appWorkflows } from "./app-workflows.js";
21
21
  import { sessionPermissions } from "../permissions.js";
22
+ import { resumePicker } from "./resume.js";
22
23
  const FRAME_MS = 16;
23
24
  const SPIN_MS = 80;
24
25
  /** How long a lone escape waits to prove it is not the start of a sequence. */
@@ -26,10 +27,11 @@ const ESCAPE_MS = 25;
26
27
  export async function runApp(session, transcriptRoot, environment = {}) {
27
28
  const terminal = environment.screen ?? realScreen;
28
29
  const paint = environment.paint ?? painter();
29
- const keys = decoder();
30
+ const keys = decoder({ ctrlBackspaceIsBs: process.env["WT_SESSION"] !== undefined });
30
31
  const transcript = transcriptRenderer();
31
32
  const workspace = await workspaceLabel(session.config.root);
32
33
  const state = appState();
34
+ state.blocks.push(...session.conversation.transcript);
33
35
  const permissions = sessionPermissions(session.tools, session.config.autoApprove);
34
36
  let closed;
35
37
  let frameTimer;
@@ -43,23 +45,28 @@ export async function runApp(session, transcriptRoot, environment = {}) {
43
45
  const done = new Promise((resolve) => {
44
46
  closed = resolve;
45
47
  });
46
- const view = () => ({
47
- blocks: state.blocks,
48
- editor: state.editor,
49
- scroll: state.scroll,
50
- unseen: state.unseen,
51
- pal: session.palette,
52
- footer: footerInfo(session, workspace),
53
- status: state.status,
54
- feedback: state.feedback,
55
- readiness: turnBlocker(session),
56
- spin: state.spin,
57
- reducedMotion: session.config.reducedMotion,
58
- now: Date.now(),
59
- modal: overlay.shown(state.open),
60
- menu: completionOptions(state.completing),
61
- menuIndex: state.completing?.index,
62
- });
48
+ const view = () => {
49
+ const now = Date.now();
50
+ return {
51
+ blocks: state.blocks,
52
+ editor: state.editor,
53
+ scroll: state.scroll,
54
+ unseen: state.unseen,
55
+ pal: session.palette,
56
+ footer: footerInfo(session, workspace),
57
+ status: state.activity === undefined
58
+ ? undefined
59
+ : activityStatus(state.activity, state.status ?? state.activity.label, now),
60
+ feedback: state.feedback,
61
+ readiness: turnBlocker(session),
62
+ spin: state.spin,
63
+ reducedMotion: session.config.reducedMotion,
64
+ now,
65
+ modal: overlay.shown(state.open),
66
+ menu: completionOptions(state.completing),
67
+ menuIndex: state.completing?.index,
68
+ };
69
+ };
63
70
  const draw = () => {
64
71
  frameTimer = undefined;
65
72
  if (!live)
@@ -201,6 +208,44 @@ export async function runApp(session, transcriptRoot, environment = {}) {
201
208
  invalidate: () => paint.invalidate(),
202
209
  transcriptChanged: (block) => transcript.invalidate(block),
203
210
  });
211
+ const resumeAtLaunch = session.resume === undefined
212
+ ? undefined
213
+ : openResumedSession(session.resume);
214
+ async function openResumedSession(launch) {
215
+ while (live) {
216
+ const index = await new Promise((resolve) => {
217
+ state.open = { picker: resumePicker(launch.candidates, session.palette), settle: resolve };
218
+ render();
219
+ });
220
+ if (index === undefined) {
221
+ quit();
222
+ return;
223
+ }
224
+ const candidate = launch.candidates[index];
225
+ if (candidate === undefined)
226
+ continue;
227
+ const activity = startActivity("command", "Opening session");
228
+ if (activity === undefined)
229
+ return;
230
+ try {
231
+ await launch.open(candidate.id);
232
+ state.blocks.splice(0, state.blocks.length, ...session.conversation.transcript);
233
+ state.past.length = 0;
234
+ state.scroll = 0;
235
+ state.follow = true;
236
+ state.unseen = 0;
237
+ state.lastMaxScroll = 0;
238
+ transcript.invalidate();
239
+ paint.invalidate();
240
+ finishActivity(activity);
241
+ return;
242
+ }
243
+ catch (error) {
244
+ feedback.show({ text: error.message, tone: "error", timeoutMs: 6_000 });
245
+ finishActivity(activity);
246
+ }
247
+ }
248
+ }
204
249
  terminal.enter(session.config.reducedMotion);
205
250
  stopResize = terminal.onResize(() => {
206
251
  paint.invalidate();
@@ -228,5 +273,12 @@ export async function runApp(session, transcriptRoot, environment = {}) {
228
273
  render();
229
274
  });
230
275
  draw();
231
- await done;
276
+ try {
277
+ await resumeAtLaunch;
278
+ if (live)
279
+ await done;
280
+ }
281
+ finally {
282
+ await session.persistence?.close();
283
+ }
232
284
  }
@@ -7,7 +7,7 @@ export function renderComposer(editor, width, maxInputRows, pal, right = "") {
7
7
  const first = Math.max(0, Math.min(laid.lines.length - room, laid.cursor.row));
8
8
  const shown = laid.lines.slice(first, first + room);
9
9
  return {
10
- rows: shown.map((line, index) => row(width, [{ text: line, fg: pal.ink.bright }], index === 0 && right !== "" ? [{ text: right, fg: pal.ink.muted }] : [])),
10
+ rows: shown.map((line, index) => row(width, [{ text: line, fg: pal.ink.bright }], index === 0 && right !== "" ? [{ text: right, fg: pal.ink.dim }] : [])),
11
11
  cursor: { row: laid.cursor.row - first, col: laid.cursor.col },
12
12
  };
13
13
  }
@@ -5,7 +5,7 @@ export function renderFooter(info, status, width, pal) {
5
5
  const right = fitSegs(status, rightLimit);
6
6
  const leftRoom = Math.max(0, width - plainLen(right) - (right.length === 0 ? 0 : 1));
7
7
  const left = identity(info, leftRoom);
8
- return [row(width, left === "" ? [] : [{ text: left, fg: pal.ink.muted }], right)];
8
+ return [row(width, left === "" ? [] : [{ text: left, fg: pal.ink.dim }], right)];
9
9
  }
10
10
  function identity(info, cols) {
11
11
  if (cols <= 0)
@@ -16,28 +16,40 @@ 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 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.
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
22
  const monochrome = !hasColor();
23
23
  const selectedMark = monochrome ? (entry.selected ? "→ " : " ") : "";
24
- const fg = entry.selected ? pal.ink.bright : pal.ink.fg;
24
+ const fg = entry.selected ? pal.focus : pal.ink.fg;
25
25
  const primary = [
26
- { text: selectedMark, fg: entry.selected ? pal.accent : fg },
27
- { text: entry.label, fg },
26
+ { text: selectedMark, fg },
27
+ { text: entry.label, fg, bold: entry.selected || undefined },
28
28
  ];
29
29
  if (width > 40 && entry.description !== undefined) {
30
30
  const gap = Math.max(2, labelWidth - primaryWidth(entry));
31
- primary.push({ text: `${" ".repeat(gap)}${entry.description}`, fg: entry.selected ? pal.ink.fg : pal.ink.muted });
31
+ primary.push({
32
+ text: `${" ".repeat(gap)}${entry.description}`,
33
+ fg: entry.selected ? pal.ink.bright : pal.ink.muted,
34
+ });
32
35
  }
33
- const right = width > 40 && entry.hint !== undefined
34
- ? [{
35
- text: elide(entry.hint, Math.max(1, Math.floor(width / 4))),
36
- fg: entry.selected ? pal.ink.fg : pal.ink.muted,
37
- }]
38
- : [];
39
- const ground = entry.selected && !monochrome ? pal.surface.inset : undefined;
40
- return row(width, primary, right, ground);
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);
41
53
  }
42
54
  function primaryWidth(entry) {
43
55
  return plainLen([{ text: entry.label }]);
@@ -31,23 +31,9 @@ export function renderReasoning(block, width, pal) {
31
31
  : { text: block.text, truncated: false };
32
32
  const content = markdown(source.text, inner, pal, inner);
33
33
  const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
34
- const action = expanded
35
- ? "ctrl+o compact"
36
- : block.live === true && block.expanded === true
37
- ? "full when done"
38
- : source.truncated || content.length > REASONING_PREVIEW_ROWS
39
- ? "ctrl+o full"
40
- : undefined;
41
34
  return [
42
35
  "",
43
- row(width, [
44
- {
45
- text: block.live === true ? "thinking" : "thought",
46
- fg: pal.ink.bright,
47
- bold: true,
48
- },
49
- ], action === undefined ? [] : [{ text: action, fg: pal.ink.muted }], undefined, PAD),
50
- ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.muted, italic: true })), [], undefined, PAD)),
36
+ ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })), [], undefined, PAD)),
51
37
  ];
52
38
  }
53
39
  export function reasoningPreviewSource(text, width) {
@@ -7,10 +7,10 @@ export const PROMPT_WIDTH = textWidth(MARK);
7
7
  export function promptLine(text, cursor, width, pal, options = {}) {
8
8
  const right = options.right === undefined || options.right === ""
9
9
  ? []
10
- : [{ text: options.right, fg: pal.ink.muted }];
10
+ : [{ text: options.right, fg: pal.ink.dim }];
11
11
  const laid = layout(text, cursor, width, options);
12
12
  const content = laid.visible === "" && options.placeholder !== undefined
13
- ? { text: options.placeholder, fg: pal.ink.muted }
13
+ ? { text: options.placeholder, fg: pal.ink.dim }
14
14
  : { text: laid.visible, fg: pal.ink.bright };
15
15
  return {
16
16
  row: row(width, [{ text: MARK, fg: pal.accent }, content], right),
@@ -6,7 +6,10 @@ export function renderStatus(info, pal) {
6
6
  if (urgent !== undefined)
7
7
  return withUnseen(feedbackSegments(urgent, pal), info.unseen, pal);
8
8
  if (info.status !== undefined) {
9
- return withUnseen([{ text: "esc to interrupt", fg: pal.ink.muted }], info.unseen, pal);
9
+ return withUnseen([
10
+ { text: info.status, fg: pal.ink.muted },
11
+ { text: " · esc to interrupt", fg: pal.ink.dim, optional: true },
12
+ ], info.unseen, pal);
10
13
  }
11
14
  if (info.feedback !== undefined) {
12
15
  return withUnseen(feedbackSegments(info.feedback, pal), info.unseen, pal);
@@ -30,7 +33,7 @@ function feedbackSegments(feedback, pal) {
30
33
  function withUnseen(status, unseen, pal) {
31
34
  return unseen === 0
32
35
  ? status
33
- : [...status, { text: " · ", fg: pal.ink.muted }, ...unseenSegments(unseen, pal)];
36
+ : [...status, { text: " · ", fg: pal.ink.dim }, ...unseenSegments(unseen, pal)];
34
37
  }
35
38
  function unseenSegments(unseen, pal) {
36
39
  return [{ text: `${unseen} new ↓`, fg: pal.accent, bold: true }];
@@ -2,6 +2,7 @@
2
2
  import { hasColor, row } from "../../ui/render.js";
3
3
  const OUTPUT_ROWS = 8;
4
4
  const LIVE_OUTPUT_ROWS = 6;
5
+ const DIFF_ROWS = 15;
5
6
  export function renderTool(block, width, pal, context = {}) {
6
7
  const shown = visibleDetails(block);
7
8
  const right = liveLabel(block, context);
@@ -11,7 +12,7 @@ export function renderTool(block, width, pal, context = {}) {
11
12
  { text: " " },
12
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
+ ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
15
16
  ], right === "" ? [] : [{ text: right, fg: statusInk(block.tone, pal) }]),
16
17
  ...shown.map((detail) => renderDetail(detail, block.tone, width, pal)),
17
18
  ];
@@ -31,8 +32,24 @@ function visibleDetails(block) {
31
32
  return [{ kind: "gap", text: note }, ...all.slice(-limit)];
32
33
  }
33
34
  // The compact transcript is an audit of what changed, not a code excerpt.
34
- // Context and gap rows remain in semantic state for the explicit full view.
35
- return all.filter((detail) => detail.kind === "add" || detail.kind === "del");
35
+ // One shared budget applies to writes and edits. Keep both ends so a large
36
+ // replacement cannot show only deletions while hiding all new content.
37
+ // Context, omitted changes, and gap rows remain in semantic state for the
38
+ // explicit full view.
39
+ const changed = all.filter((detail) => detail.kind === "add" || detail.kind === "del");
40
+ if (changed.length <= DIFF_ROWS)
41
+ return changed;
42
+ const leading = Math.ceil(DIFF_ROWS / 2);
43
+ const trailing = DIFF_ROWS - leading;
44
+ const hidden = changed.length - DIFF_ROWS;
45
+ return [
46
+ ...changed.slice(0, leading),
47
+ {
48
+ kind: "gap",
49
+ text: `… ${hidden} more changed ${hidden === 1 ? "line" : "lines"} · ctrl+o expand`,
50
+ },
51
+ ...changed.slice(-trailing),
52
+ ];
36
53
  }
37
54
  function renderDetail(detail, tone, width, pal) {
38
55
  const lead = { text: " " };
@@ -42,7 +59,7 @@ function renderDetail(detail, tone, width, pal) {
42
59
  return row(width, [lead, rail, { text: detail.text === "" ? " " : detail.text, fg }]);
43
60
  }
44
61
  if (detail.kind === "gap") {
45
- return row(width, [lead, rail, { text: detail.text, fg: pal.ink.muted, italic: true }]);
62
+ return row(width, [lead, rail, { text: detail.text, fg: pal.ink.dim, italic: true }]);
46
63
  }
47
64
  const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
48
65
  const prefix = `${detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " "}${String(number ?? "").padStart(3)} `;
@@ -50,7 +67,7 @@ function renderDetail(detail, tone, width, pal) {
50
67
  ? pal.ink.added
51
68
  : detail.kind === "del"
52
69
  ? pal.ink.removed
53
- : pal.ink.muted;
70
+ : pal.ink.dim;
54
71
  return row(width, [lead, rail, { text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)]);
55
72
  }
56
73
  function emphasized(text, emphasis, fg) {
@@ -43,15 +43,25 @@ export function wordLeft(state) {
43
43
  return { ...state, cursor: startOfWordBefore(state.text, state.cursor) };
44
44
  }
45
45
  export function wordRight(state) {
46
- return { ...state, cursor: endOfWordAfter(state.text, state.cursor) };
46
+ return { ...state, cursor: startOfWordAfter(state.text, state.cursor) };
47
47
  }
48
48
  /** ctrl+w — delete the word behind the cursor. */
49
49
  export function killWord(state) {
50
- const from = startOfWordBefore(state.text, state.cursor);
50
+ const from = startOfDeletionBefore(state.text, state.cursor);
51
51
  if (from === state.cursor)
52
52
  return state;
53
53
  return { text: state.text.slice(0, from) + state.text.slice(state.cursor), cursor: from };
54
54
  }
55
+ /** ctrl+delete — delete the word ahead of the cursor. */
56
+ export function killNextWord(state) {
57
+ const to = endOfDeletionAfter(state.text, state.cursor);
58
+ if (to === state.cursor)
59
+ return state;
60
+ return {
61
+ text: state.text.slice(0, state.cursor) + state.text.slice(to),
62
+ cursor: state.cursor,
63
+ };
64
+ }
55
65
  /** ctrl+u — delete everything behind the cursor. */
56
66
  export function killToStart(state) {
57
67
  return { text: state.text.slice(state.cursor), cursor: 0 };
@@ -89,17 +99,54 @@ function after(text, cursor) {
89
99
  }
90
100
  function startOfWordBefore(text, cursor) {
91
101
  let i = cursor;
92
- while (i > 0 && text[i - 1] === " ")
102
+ while (i > 0 && whitespace(text[i - 1]))
93
103
  i--;
94
- while (i > 0 && text[i - 1] !== " ")
104
+ while (i > 0 && !whitespace(text[i - 1]))
95
105
  i--;
96
106
  return i;
97
107
  }
98
- function endOfWordAfter(text, cursor) {
108
+ function startOfWordAfter(text, cursor) {
99
109
  let i = cursor;
100
- while (i < text.length && text[i] === " ")
110
+ while (i < text.length && !whitespace(text[i]))
101
111
  i++;
102
- while (i < text.length && text[i] !== " ")
112
+ while (i < text.length && whitespace(text[i]))
103
113
  i++;
104
114
  return i;
105
115
  }
116
+ function startOfDeletionBefore(text, cursor) {
117
+ if (text[cursor - 1] === "\n")
118
+ return cursor - 1;
119
+ let i = cursor;
120
+ while (i > 0 && horizontalWhitespace(text[i - 1]))
121
+ i--;
122
+ if (text[i - 1] === "\n")
123
+ return i;
124
+ while (i > 0 && !whitespace(text[i - 1]))
125
+ i--;
126
+ return i;
127
+ }
128
+ function endOfDeletionAfter(text, cursor) {
129
+ if (text[cursor] === "\n")
130
+ return cursor + 1;
131
+ let i = cursor;
132
+ if (horizontalWhitespace(text[i])) {
133
+ while (i < text.length && horizontalWhitespace(text[i]))
134
+ i++;
135
+ if (text[i] === "\n")
136
+ return i;
137
+ while (i < text.length && !whitespace(text[i]))
138
+ i++;
139
+ return i;
140
+ }
141
+ while (i < text.length && !whitespace(text[i]))
142
+ i++;
143
+ while (i < text.length && horizontalWhitespace(text[i]))
144
+ i++;
145
+ return i;
146
+ }
147
+ function horizontalWhitespace(value) {
148
+ return value !== undefined && value !== "\n" && /\s/u.test(value);
149
+ }
150
+ function whitespace(value) {
151
+ return value !== undefined && /\s/u.test(value);
152
+ }