@giovannijecha/jecode 0.7.3 → 0.8.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 +104 -29
  2. package/dist/atomic.js +14 -0
  3. package/dist/batch.js +5 -2
  4. package/dist/cli-info.js +1 -1
  5. package/dist/config.js +4 -1
  6. package/dist/context/policy.js +14 -7
  7. package/dist/controller-request.js +1 -1
  8. package/dist/controller.js +39 -15
  9. package/dist/duration.js +8 -0
  10. package/dist/oauth-http.js +2 -1
  11. package/dist/openai-account.js +12 -8
  12. package/dist/openai-oauth-callback.js +2 -1
  13. package/dist/providers/anthropic-stream.js +8 -0
  14. package/dist/providers/anthropic-wire.js +22 -10
  15. package/dist/providers/http.js +4 -3
  16. package/dist/providers/ollama-stream.js +7 -0
  17. package/dist/providers/ollama-wire.js +11 -3
  18. package/dist/providers/openai-codex.js +0 -2
  19. package/dist/providers/openai-stream.js +57 -2
  20. package/dist/providers/openai-wire.js +27 -12
  21. package/dist/providers/sse.js +115 -38
  22. package/dist/sessions/codec.js +13 -5
  23. package/dist/sessions/store.js +2 -1
  24. package/dist/settings-command.js +0 -5
  25. package/dist/settings.js +0 -2
  26. package/dist/steering.js +58 -0
  27. package/dist/text-boundary.js +47 -0
  28. package/dist/timeline.js +2 -1
  29. package/dist/tools/args.js +1 -1
  30. package/dist/tools/fs.js +5 -0
  31. package/dist/tools/text-boundary.js +1 -31
  32. package/dist/transcript.js +6 -1
  33. package/dist/tui/activity.js +20 -5
  34. package/dist/tui/app-input.js +31 -6
  35. package/dist/tui/app-workflows.js +57 -14
  36. package/dist/tui/app.js +18 -3
  37. package/dist/tui/blocks.js +7 -2
  38. package/dist/tui/components/messages.js +20 -17
  39. package/dist/tui/components/misc.js +7 -6
  40. package/dist/tui/components/status.js +12 -1
  41. package/dist/tui/components/tool.js +152 -50
  42. package/dist/tui/help.js +1 -1
  43. package/dist/tui/motion.js +32 -0
  44. package/dist/tui/session-view.js +3 -2
  45. package/dist/tui/transcript-grammar.js +25 -0
  46. package/dist/tui/transcript-view.js +132 -7
  47. package/dist/tui/turn.js +78 -39
  48. package/dist/tui/view.js +4 -2
  49. package/dist/ui/diff.js +51 -16
  50. package/dist/ui/render.js +17 -27
  51. package/dist/ui/theme.js +19 -18
  52. package/dist/ui/width.js +31 -22
  53. package/dist/usage.js +5 -1
  54. package/package.json +1 -1
@@ -0,0 +1,47 @@
1
+ // Shared grapheme boundaries for every projection of user-visible text.
2
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
3
+ export function segmentGraphemes(text) {
4
+ return SEGMENTER.segment(text);
5
+ }
6
+ export function graphemes(text) {
7
+ const out = [];
8
+ for (const { segment } of segmentGraphemes(text))
9
+ out.push(segment);
10
+ return out;
11
+ }
12
+ /** Largest complete grapheme boundary no greater than a UTF-16 offset. */
13
+ export function graphemeFloor(text, offset) {
14
+ const target = Math.max(0, Math.min(text.length, offset));
15
+ if (target === 0 || target === text.length)
16
+ return target;
17
+ const containing = segmentGraphemes(text).containing(target);
18
+ if (containing === undefined || containing.index === target)
19
+ return target;
20
+ return containing.index;
21
+ }
22
+ /** Smallest complete grapheme boundary no less than a UTF-16 offset. */
23
+ export function graphemeCeiling(text, offset) {
24
+ const target = Math.max(0, Math.min(text.length, offset));
25
+ if (target === 0 || target === text.length)
26
+ return target;
27
+ const containing = segmentGraphemes(text).containing(target);
28
+ if (containing === undefined || containing.index === target)
29
+ return target;
30
+ return containing.index + containing.segment.length;
31
+ }
32
+ /** Keep a bounded prefix without returning part of a user-perceived character. */
33
+ export function leadingText(text, maxCodeUnits) {
34
+ if (maxCodeUnits <= 0)
35
+ return "";
36
+ if (text.length <= maxCodeUnits)
37
+ return text;
38
+ return text.slice(0, graphemeFloor(text, maxCodeUnits));
39
+ }
40
+ /** Keep a bounded suffix without returning part of a user-perceived character. */
41
+ export function trailingText(text, maxCodeUnits) {
42
+ if (maxCodeUnits <= 0)
43
+ return "";
44
+ if (text.length <= maxCodeUnits)
45
+ return text;
46
+ return text.slice(graphemeCeiling(text, text.length - maxCodeUnits));
47
+ }
package/dist/timeline.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // Selecting a node changes only the in-memory path. The next real user turn
4
4
  // is what persists a branch, so opening or cancelling this control plane can
5
5
  // never create empty history.
6
+ import { leadingText } from "./text-boundary.js";
6
7
  import { usageFromHistory } from "./usage.js";
7
8
  import { heading } from "./tui/picker.js";
8
9
  export function timelinePicker(conversation, palette) {
@@ -83,7 +84,7 @@ function preview(node) {
83
84
  const text = message.content.find((block) => block.kind === "text")?.text
84
85
  .replace(/\s+/gu, " ").trim();
85
86
  if (text !== undefined && text !== "")
86
- return text.slice(0, 160);
87
+ return leadingText(text, 160);
87
88
  }
88
89
  return "Untitled turn";
89
90
  }
@@ -2,7 +2,7 @@
2
2
  // tool takes three arguments and this is the whole job.
3
3
  //
4
4
  // Every throw here becomes an is_error tool result the model can read and
5
- // correct on the next step, so the messages are written for that reader.
5
+ // correct on the next request, so the messages are written for that reader.
6
6
  export function requireString(args, name, allowEmpty = false) {
7
7
  const value = args[name];
8
8
  if (typeof value !== "string" || (!allowEmpty && value === "")) {
package/dist/tools/fs.js CHANGED
@@ -120,15 +120,18 @@ export const writeFile = {
120
120
  },
121
121
  };
122
122
  export async function runWriteFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
123
+ throwIfAborted(ctx.signal);
123
124
  const root = await resolveExistingInRoot(ctx.root, ".");
124
125
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
125
126
  const content = requireString(args, "content", true);
126
127
  assertEditableText(content);
127
128
  await fs.mkdir(path.dirname(target), { recursive: true });
129
+ throwIfAborted(ctx.signal);
128
130
  await assertDirectWritableInRoot(root, target);
129
131
  const before = await current(target);
130
132
  assertApproved(before, ctx.preview, "write");
131
133
  await dependencies.atomicWrite(target, content, {
134
+ signal: ctx.signal,
132
135
  async validate(phase) {
133
136
  await assertDirectWritableInRoot(root, target);
134
137
  if (phase === "before-rename") {
@@ -181,12 +184,14 @@ export const editFile = {
181
184
  },
182
185
  };
183
186
  export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
187
+ throwIfAborted(ctx.signal);
184
188
  const root = await resolveExistingInRoot(ctx.root, ".");
185
189
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
186
190
  const before = await current(target, true);
187
191
  assertApproved(before, ctx.preview, "edit");
188
192
  const { after, made } = applied(before.text, args);
189
193
  await dependencies.atomicWrite(target, after, {
194
+ signal: ctx.signal,
190
195
  async validate(phase) {
191
196
  await assertDirectWritableInRoot(root, target, true);
192
197
  if (phase === "before-rename") {
@@ -1,41 +1,11 @@
1
1
  // Shared size and truncation boundaries for text handled by workspace tools.
2
2
  import { constants } from "node:fs";
3
3
  import { lstat, open } from "node:fs/promises";
4
+ export { leadingText, trailingText } from "../text-boundary.js";
4
5
  export const MAX_EDITABLE_BYTES = 4_000_000;
5
6
  export const MAX_EDITABLE_CHARS = 1_000_000;
6
7
  export const MAX_EDITABLE_LINES = 20_000;
7
8
  const READ_CHUNK_BYTES = 64 * 1024;
8
- const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
9
- /** Keep a bounded prefix without returning part of a user-perceived character. */
10
- export function leadingText(text, maxCodeUnits) {
11
- if (maxCodeUnits <= 0)
12
- return "";
13
- if (text.length <= maxCodeUnits)
14
- return text;
15
- let end = 0;
16
- for (const { index, segment } of GRAPHEME_SEGMENTER.segment(text)) {
17
- const next = index + segment.length;
18
- if (next > maxCodeUnits)
19
- break;
20
- end = next;
21
- }
22
- return text.slice(0, end);
23
- }
24
- /** Keep a bounded suffix without returning part of a user-perceived character. */
25
- export function trailingText(text, maxCodeUnits) {
26
- if (maxCodeUnits <= 0)
27
- return "";
28
- if (text.length <= maxCodeUnits)
29
- return text;
30
- let start = text.length;
31
- for (const { index } of GRAPHEME_SEGMENTER.segment(text)) {
32
- if (text.length - index <= maxCodeUnits) {
33
- start = index;
34
- break;
35
- }
36
- }
37
- return text.slice(start);
38
- }
39
9
  /** Read a regular UTF-8 file without allowing an unbounded allocation. */
40
10
  export async function readEditableText(file, options = {}) {
41
11
  const label = options.label ?? "file";
@@ -1,5 +1,6 @@
1
1
  // A portable transcript: screen blocks in, Markdown out.
2
2
  import { terminalText } from "./ui/terminal-text.js";
3
+ import { toolDuration } from "./duration.js";
3
4
  export function defaultTranscriptName(now = new Date()) {
4
5
  return `jecode-transcript-${now.toISOString().replace(/[-:.]/g, "")}.md`;
5
6
  }
@@ -17,7 +18,11 @@ export function transcriptMarkdown(blocks) {
17
18
  out.push("<details>", "<summary>Reasoning</summary>", "", safeMultiline(block.text), "", "</details>", "");
18
19
  break;
19
20
  case "tool":
20
- out.push(`- **${safeInline(block.name)}**${block.target === "" ? "" : ` \`${inlineCode(block.target)}\``}${block.right === "" ? "" : ` — ${safeInline(block.right)}`}`);
21
+ const outcome = [
22
+ block.right === "" ? undefined : safeInline(block.right),
23
+ block.durationMs === undefined ? undefined : toolDuration(block.durationMs),
24
+ ].filter((part) => part !== undefined).join(" · ");
25
+ out.push(`- **${safeInline(block.name)}**${block.target === "" ? "" : ` \`${inlineCode(block.target)}\``}${outcome === "" ? "" : ` — ${outcome}`}`);
21
26
  if ((block.body?.length ?? 0) > 0) {
22
27
  out.push("", "````text", ...(block.body ?? []).map(detail), "````", "");
23
28
  }
@@ -1,14 +1,29 @@
1
1
  // One foreground operation owns cancellation and elapsed time.
2
2
  export function begin(kind, label, now = Date.now()) {
3
- return { kind, label, control: new AbortController(), startedAt: now };
3
+ return {
4
+ kind,
5
+ label,
6
+ control: new AbortController(),
7
+ startedAt: now,
8
+ phase: { label, startedAt: now },
9
+ };
10
+ }
11
+ /** Change the visible phase without restarting its timer on repeated stream chunks. */
12
+ export function transition(activity, label, now = Date.now()) {
13
+ if (activity.phase.label === label)
14
+ return;
15
+ activity.phase = { label, startedAt: now };
4
16
  }
5
17
  export function elapsed(activity, now = Date.now()) {
6
- const seconds = Math.max(0, Math.floor((now - activity.startedAt) / 1_000));
18
+ return since(activity.startedAt, now);
19
+ }
20
+ export function activityStatus(activity, now = Date.now()) {
21
+ return `${activity.phase.label} · ${since(activity.phase.startedAt, now)}`;
22
+ }
23
+ function since(startedAt, now) {
24
+ const seconds = Math.max(0, Math.floor((now - startedAt) / 1_000));
7
25
  if (seconds < 60)
8
26
  return `${seconds}s`;
9
27
  const minutes = Math.floor(seconds / 60);
10
28
  return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
11
29
  }
12
- export function activityStatus(activity, label = activity.label, now = Date.now()) {
13
- return `${label} · ${elapsed(activity, now)}`;
14
- }
@@ -69,6 +69,8 @@ export function appInput(options) {
69
69
  return;
70
70
  }
71
71
  case "tab": {
72
+ if (state.activity !== undefined)
73
+ return;
72
74
  const completion = state.completing ?? activateCompletion(state.editor.text);
73
75
  const completed = completion === undefined ? undefined : selectedCompletion(completion);
74
76
  if (completed !== undefined) {
@@ -107,7 +109,7 @@ export function appInput(options) {
107
109
  const edited = applyKey(state.editor, key);
108
110
  if (edited !== undefined) {
109
111
  state.editor = edited;
110
- state.completing = activateCompletion(edited.text);
112
+ state.completing = state.activity === undefined ? activateCompletion(edited.text) : undefined;
111
113
  }
112
114
  }
113
115
  function recall(step) {
@@ -131,9 +133,26 @@ export function appInput(options) {
131
133
  }
132
134
  function submit() {
133
135
  const text = state.editor.text.trim();
134
- if (text === "" || state.activity !== undefined)
136
+ if (text === "")
135
137
  return;
136
138
  const isCommand = text.startsWith("/");
139
+ if (state.activity !== undefined) {
140
+ if (isCommand) {
141
+ keep("Slash commands run after the active work finishes");
142
+ return;
143
+ }
144
+ const result = actions.steer(text);
145
+ if (result !== "queued") {
146
+ keep(result === "full"
147
+ ? "Steering queue is full"
148
+ : result === "closed"
149
+ ? "The turn is finishing"
150
+ : "Wait for the active command to finish");
151
+ return;
152
+ }
153
+ accept(text);
154
+ return;
155
+ }
137
156
  if (!isCommand) {
138
157
  const blocker = turnBlocker(session);
139
158
  if (blocker !== undefined) {
@@ -141,6 +160,13 @@ export function appInput(options) {
141
160
  return;
142
161
  }
143
162
  }
163
+ accept(text);
164
+ if (isCommand)
165
+ void actions.command(text);
166
+ else
167
+ void actions.turn(text);
168
+ }
169
+ function accept(text) {
144
170
  state.editor = edit.EMPTY;
145
171
  state.recall = -1;
146
172
  state.draft = "";
@@ -149,10 +175,9 @@ export function appInput(options) {
149
175
  state.scroll = 0;
150
176
  state.follow = true;
151
177
  state.unseen = 0;
152
- if (isCommand)
153
- void actions.command(text);
154
- else
155
- void actions.turn(text);
178
+ }
179
+ function keep(text) {
180
+ feedback.show({ text: `${text} · prompt kept`, tone: "warn", timeoutMs: 4_000 });
156
181
  }
157
182
  return { handle };
158
183
  }
@@ -6,9 +6,11 @@ import { compactContext } from "../context/compactor.js";
6
6
  import { compactSession } from "../context/manual.js";
7
7
  import { isContextOverflow } from "../context/policy.js";
8
8
  import { updateSettings } from "../settings.js";
9
+ import { steeringInbox } from "../steering.js";
9
10
  import { saveTranscript } from "../transcript-export.js";
10
- import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
11
+ import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "../usage.js";
11
12
  import { selectTimeline } from "../timeline.js";
13
+ import { transition } from "./activity.js";
12
14
  import { answerAt } from "./approve.js";
13
15
  import * as edit from "./editor.js";
14
16
  import { cancel as cancelOpen } from "./overlay.js";
@@ -17,6 +19,7 @@ import { transcribe } from "./turn.js";
17
19
  const WAITING = "Waiting";
18
20
  export function appWorkflows(options) {
19
21
  const { session, state, permissions, feedback } = options;
22
+ let activeSteering;
20
23
  const choose = (picker) => new Promise((resolve) => {
21
24
  state.open = { picker, settle: resolve };
22
25
  options.render();
@@ -25,6 +28,7 @@ export function appWorkflows(options) {
25
28
  const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
26
29
  if (activity === undefined)
27
30
  return;
31
+ const status = (label) => transition(activity, label);
28
32
  try {
29
33
  const outcome = await handleCommand(text, session, {
30
34
  emit: options.commandNotice,
@@ -43,7 +47,7 @@ export function appWorkflows(options) {
43
47
  options.render();
44
48
  }),
45
49
  status: (said) => {
46
- state.status = said ?? activity.label;
50
+ status(said ?? activity.label);
47
51
  options.render();
48
52
  },
49
53
  reset: async () => {
@@ -78,8 +82,8 @@ export function appWorkflows(options) {
78
82
  }
79
83
  return compactSession(session, {
80
84
  signal: activity.control.signal,
81
- onStatus: (status) => {
82
- state.status = status ?? activity.label;
85
+ onStatus: (said) => {
86
+ status(said ?? activity.label);
83
87
  options.render();
84
88
  },
85
89
  });
@@ -108,6 +112,15 @@ export function appWorkflows(options) {
108
112
  const activity = options.startActivity("turn", WAITING);
109
113
  if (activity === undefined)
110
114
  return;
115
+ const inbox = steeringInbox((pending, accepting) => {
116
+ if (activeSteering !== inbox)
117
+ return;
118
+ state.steering = accepting ? pending : undefined;
119
+ options.render();
120
+ });
121
+ activeSteering = inbox;
122
+ state.steering = 0;
123
+ const status = (label) => transition(activity, label);
111
124
  const parentId = session.conversation.activeNodeId;
112
125
  const history = session.conversation.history;
113
126
  const modelHistory = session.conversation.contextHistory;
@@ -117,12 +130,13 @@ export function appWorkflows(options) {
117
130
  const prospectiveNodeId = session.conversation.nodes.length + 1;
118
131
  let nodeId;
119
132
  let context;
133
+ const unpersistedSteering = [];
120
134
  let firstPolicy = true;
121
135
  const policy = () => {
122
136
  let visible = firstPolicy;
123
137
  firstPolicy = false;
124
138
  if (visible) {
125
- state.status = "Checking context";
139
+ status("Checking context");
126
140
  options.render();
127
141
  }
128
142
  return resolveContextPolicy({
@@ -130,14 +144,14 @@ export function appWorkflows(options) {
130
144
  model: session.model,
131
145
  compactionPercent: session.config.compactionPercent,
132
146
  signal: activity.control.signal,
133
- onStatus: (status) => {
147
+ onStatus: (said) => {
134
148
  visible = true;
135
- state.status = status;
149
+ status(said);
136
150
  options.render();
137
151
  },
138
152
  }).finally(() => {
139
153
  if (visible) {
140
- state.status = WAITING;
154
+ status(WAITING);
141
155
  options.render();
142
156
  }
143
157
  });
@@ -157,9 +171,10 @@ export function appWorkflows(options) {
157
171
  options.render();
158
172
  },
159
173
  status: (text) => {
160
- state.status = text;
174
+ status(text);
161
175
  },
162
176
  usage: (usage) => recordUsage(session.usage, usage),
177
+ requestInput: (inputTokens) => recordRequestInput(session.usage, inputTokens),
163
178
  });
164
179
  const persist = async (checkpoint, settlement, failure) => {
165
180
  const next = session.conversation.commit({
@@ -180,6 +195,7 @@ export function appWorkflows(options) {
180
195
  session.conversation = next;
181
196
  nodeId = next.activeNodeId;
182
197
  state.committedNodeId = next.activeNodeId;
198
+ unpersistedSteering.length = 0;
183
199
  };
184
200
  const compact = async (checkpoint, projected, request) => {
185
201
  if (request.reason === "overflow" &&
@@ -200,11 +216,11 @@ export function appWorkflows(options) {
200
216
  force,
201
217
  policy: request.policy,
202
218
  onBegin: () => {
203
- state.status = "Compacting";
219
+ status("Compacting");
204
220
  options.render();
205
221
  },
206
222
  onEnd: () => {
207
- state.status = WAITING;
223
+ status(WAITING);
208
224
  options.render();
209
225
  },
210
226
  });
@@ -216,6 +232,11 @@ export function appWorkflows(options) {
216
232
  return result.messages;
217
233
  };
218
234
  events.onContext = compact;
235
+ events.onSteering = (guidance) => {
236
+ unpersistedSteering.push(guidance);
237
+ options.emit({ kind: "user", text: guidance });
238
+ options.render();
239
+ };
219
240
  events.onCheckpoint = async (checkpoint, settlement, projected) => {
220
241
  await persist(checkpoint, settlement);
221
242
  const compacted = await compact(checkpoint, projected, {
@@ -230,7 +251,7 @@ export function appWorkflows(options) {
230
251
  let finishReason;
231
252
  let failed;
232
253
  try {
233
- await runTurn(history, controllerOptions(session, policy, permissions.availableTools()), events, activity.control.signal, modelHistory);
254
+ await runTurn(history, controllerOptions(session, policy, permissions.availableTools(), inbox), events, activity.control.signal, modelHistory);
234
255
  }
235
256
  catch (error) {
236
257
  const interrupted = activity.control.signal.aborted;
@@ -246,6 +267,10 @@ export function appWorkflows(options) {
246
267
  }
247
268
  }
248
269
  finally {
270
+ let pendingSteering = inbox.close();
271
+ if (activeSteering === inbox)
272
+ activeSteering = undefined;
273
+ state.steering = undefined;
249
274
  try {
250
275
  events.finish(finishReason);
251
276
  if (failed !== undefined) {
@@ -264,7 +289,14 @@ export function appWorkflows(options) {
264
289
  // been saved. Revert to the last durable path and return the input
265
290
  // to the composer so the user can retry without losing it.
266
291
  options.replaceTranscript();
267
- state.editor = edit.of(text);
292
+ state.editor = edit.of([
293
+ ...(nodeId === undefined ? [text] : []),
294
+ ...unpersistedSteering,
295
+ ...pendingSteering,
296
+ state.editor.text,
297
+ ].filter((part) => part !== "").join("\n\n"));
298
+ state.completing = undefined;
299
+ pendingSteering = [];
268
300
  feedback.show({
269
301
  text: error.message,
270
302
  tone: "error",
@@ -274,11 +306,22 @@ export function appWorkflows(options) {
274
306
  }
275
307
  }
276
308
  finally {
309
+ restorePendingSteering(state, pendingSteering);
277
310
  options.finishActivity(activity);
278
311
  }
279
312
  }
280
313
  }
281
- return { command, turn };
314
+ function steer(text) {
315
+ return activeSteering?.offer(text) ?? "unavailable";
316
+ }
317
+ return { command, turn, steer };
318
+ }
319
+ function restorePendingSteering(state, messages) {
320
+ if (messages.length === 0)
321
+ return;
322
+ const pending = messages.join("\n\n");
323
+ state.editor = edit.of(state.editor.text === "" ? pending : `${pending}\n\n${state.editor.text}`);
324
+ state.completing = undefined;
282
325
  }
283
326
  function closeFailedTurn(history, settlement) {
284
327
  const closed = [...history];
package/dist/tui/app.js CHANGED
@@ -21,6 +21,7 @@ import { appWorkflows } from "./app-workflows.js";
21
21
  import { sessionPermissions } from "../permissions.js";
22
22
  import { resumePicker } from "./resume.js";
23
23
  const FRAME_MS = 16;
24
+ const MOTION_FRAME_MS = 40;
24
25
  const ACTIVITY_REFRESH_MS = 1_000;
25
26
  /** How long a lone escape waits to prove it is not the start of a sequence. */
26
27
  const ESCAPE_MS = 25;
@@ -36,6 +37,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
36
37
  const permissions = sessionPermissions(session.tools, session.config.autoApprove);
37
38
  let closed;
38
39
  let frameTimer;
40
+ let motionTimer;
39
41
  let activityTimer;
40
42
  let escapeTimer;
41
43
  let stopResize = () => { };
@@ -67,10 +69,12 @@ export async function runApp(session, transcriptRoot, environment = {}) {
67
69
  footer: footerInfo(session, workspace),
68
70
  status: state.activity === undefined
69
71
  ? undefined
70
- : activityStatus(state.activity, state.status ?? state.activity.label, now),
72
+ : activityStatus(state.activity, now),
73
+ steering: state.steering,
71
74
  feedback: state.feedback,
72
75
  readiness: turnBlocker(session),
73
76
  now,
77
+ reducedMotion: session.config.reducedMotion,
74
78
  modal: overlay.shown(state.open),
75
79
  menu: completionOptions(state.completing),
76
80
  menuIndex: state.completing?.index,
@@ -98,6 +102,16 @@ export async function runApp(session, transcriptRoot, environment = {}) {
98
102
  paint.paint(frame.rows, frame.cursor);
99
103
  if (frame.transcriptPending)
100
104
  render();
105
+ if (frame.transcriptAnimating && motionTimer === undefined) {
106
+ motionTimer = setTimeout(() => guard(() => {
107
+ motionTimer = undefined;
108
+ render();
109
+ }), Math.max(0, MOTION_FRAME_MS - FRAME_MS));
110
+ }
111
+ else if (!frame.transcriptAnimating && motionTimer !== undefined) {
112
+ clearTimeout(motionTimer);
113
+ motionTimer = undefined;
114
+ }
101
115
  };
102
116
  // Streaming produces a token at a time; painting at that rate is wasted
103
117
  // work, so repaints coalesce onto one frame.
@@ -148,6 +162,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
148
162
  clearInterval(activityTimer);
149
163
  if (frameTimer !== undefined)
150
164
  clearTimeout(frameTimer);
165
+ if (motionTimer !== undefined)
166
+ clearTimeout(motionTimer);
151
167
  if (escapeTimer !== undefined)
152
168
  clearTimeout(escapeTimer);
153
169
  safely(() => feedback.close());
@@ -193,7 +209,6 @@ export async function runApp(session, transcriptRoot, environment = {}) {
193
209
  return undefined;
194
210
  const activity = begin(kind, label);
195
211
  state.activity = activity;
196
- state.status = label;
197
212
  activityTimer = setInterval(() => guard(() => {
198
213
  let activeTool;
199
214
  for (let index = state.blocks.length - 1; index >= 0; index--) {
@@ -215,7 +230,6 @@ export async function runApp(session, transcriptRoot, environment = {}) {
215
230
  clearInterval(activityTimer);
216
231
  activityTimer = undefined;
217
232
  state.activity = undefined;
218
- state.status = undefined;
219
233
  state.open = overlay.cancel(state.open);
220
234
  if (state.closeWhenIdle)
221
235
  quit();
@@ -248,6 +262,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
248
262
  actions: {
249
263
  command: (text) => track(actions.command(text)),
250
264
  turn: (text) => track(actions.turn(text)),
265
+ steer: (text) => actions.steer(text),
251
266
  },
252
267
  live: () => live,
253
268
  quit,
@@ -9,11 +9,16 @@ export function render(block, width, pal, context = {}) {
9
9
  case "answer":
10
10
  return renderAnswer(block, width, pal);
11
11
  case "reasoning":
12
- return renderReasoning(block, width, pal);
12
+ return renderReasoning(block, width, pal, {
13
+ continues: context.previous?.kind === "tool" || context.previous?.kind === "reasoning",
14
+ });
13
15
  case "tool":
14
16
  return renderTool(block, width, pal, {
15
- continues: context.previous?.kind === "tool",
17
+ continues: context.previous?.kind === "tool" || context.previous?.kind === "reasoning",
18
+ followsReasoning: context.previous?.kind === "reasoning",
16
19
  now: context.now,
20
+ motion: context.motion,
21
+ reducedMotion: context.reducedMotion,
17
22
  });
18
23
  case "notice":
19
24
  return renderNotice(block, width, pal);
@@ -1,28 +1,32 @@
1
- import { blank, row } from "../../ui/render.js";
1
+ import { trailingText } from "../../text-boundary.js";
2
+ import { row } from "../../ui/render.js";
2
3
  import { markdown } from "../../ui/markdown.js";
3
- const PAD = 1;
4
+ import { transcriptLead, transcriptWidth } from "../transcript-grammar.js";
4
5
  export const REASONING_PREVIEW_ROWS = 3;
5
6
  const MIN_REASONING_PREVIEW_CHARS = 4_096;
6
7
  const REASONING_PREVIEW_OVERSCAN = 12;
7
8
  export function renderUser(block, width, pal) {
8
- const inner = Math.max(8, width - PAD * 2);
9
+ const inner = transcriptWidth(width);
9
10
  const content = markdown(block.text, inner, pal, inner);
10
11
  return [
11
12
  "",
12
- blank(width, pal.surface.subtle),
13
- ...content.map((line) => row(width, line.segs, [], pal.surface.subtle, PAD)),
14
- blank(width, pal.surface.subtle),
13
+ ...content.map((line, index) => row(width, [
14
+ ...transcriptLead(width, index === 0
15
+ ? { text: "❯", fg: pal.accent, bold: true }
16
+ : undefined),
17
+ ...line.segs,
18
+ ])),
15
19
  ];
16
20
  }
17
21
  export function renderAnswer(block, width, pal) {
18
- const inner = Math.max(8, width - PAD * 2);
22
+ const inner = transcriptWidth(width);
19
23
  return [
20
24
  "",
21
- ...markdown(block.text, inner, pal, inner).map((line) => row(width, line.segs, [], undefined, PAD)),
25
+ ...markdown(block.text, inner, pal, inner).map((line) => row(width, [...transcriptLead(width), ...line.segs])),
22
26
  ];
23
27
  }
24
- export function renderReasoning(block, width, pal) {
25
- const inner = Math.max(8, width - PAD * 2);
28
+ export function renderReasoning(block, width, pal, context = {}) {
29
+ const inner = transcriptWidth(width);
26
30
  // Expanding a live stream is deferred until it is sealed. Re-parsing an
27
31
  // ever-growing full thought on every token makes the whole TUI stall.
28
32
  const expanded = block.expanded === true && block.live !== true;
@@ -32,8 +36,11 @@ export function renderReasoning(block, width, pal) {
32
36
  const content = markdown(source.text, inner, pal, inner);
33
37
  const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
34
38
  return [
35
- "",
36
- ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })), [], undefined, PAD)),
39
+ ...(context.continues === true ? [] : [""]),
40
+ ...visible.map((line) => row(width, [
41
+ ...transcriptLead(width, { text: "│", fg: pal.rule }),
42
+ ...line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })),
43
+ ])),
37
44
  ];
38
45
  }
39
46
  export function reasoningPreviewSource(text, width) {
@@ -42,9 +49,5 @@ export function reasoningPreviewSource(text, width) {
42
49
  return { text, truncated: false };
43
50
  // A compact view only needs its visible tail. The complete text remains on
44
51
  // the block for expansion after the reasoning stream is sealed.
45
- let start = text.length - limit;
46
- const code = text.charCodeAt(start);
47
- if (code >= 0xdc00 && code <= 0xdfff)
48
- start--;
49
- return { text: text.slice(start), truncated: true };
52
+ return { text: trailingText(text, limit), truncated: true };
50
53
  }