s-app-scoder-agent 0.8.20 → 0.9.2

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/dist/tui/app.js CHANGED
@@ -18,8 +18,8 @@ import { setQuiet } from "../ui.js";
18
18
  import { discoverSkills } from "../skills.js";
19
19
  import { discoverAgents } from "../agents.js";
20
20
  import { resolveSessionId } from "../session.js";
21
- import { applyEvent, initialState, markAborted } from "./state.js";
22
- import { parseSlashCommand } from "./commands.js";
21
+ import { applyEvent, initialState, markAborted, estimateItemRows } from "./state.js";
22
+ import { parseSlashCommand, SLASH_COMMANDS, formatSlashHelp } from "./commands.js";
23
23
  import { dispatchCommand } from "./command-registry.js";
24
24
  import { ChatItemView } from "./chat-views.js"; // T2
25
25
  import { BuildRail, StatusBar, SIDEBAR_MIN_COLS } from "./chrome.js"; // T2
@@ -33,26 +33,31 @@ import { PermissionModal } from "./permission-modal.js"; // T2
33
33
  import { Sidebar } from "./sidebar.js"; // sidebar opencode
34
34
  import { getVersion } from "../version.js";
35
35
  export function TuiApp({ config, initialTask }) {
36
+ // v2: the effective config can be hot-patched by slash commands (/agent,
37
+ // /permission) — the prop is the initial value, cfg is the live one.
38
+ const [cfg, setCfg] = useState(config);
36
39
  const { exit } = useApp();
37
40
  // T-09: theme resolve at mount (.scoder/theme.json > builtin). Proxy-based
38
41
  // theme reads re-render via a bump.
39
42
  const [, bump] = useState(0);
40
43
  const bumpFrame = useCallback(() => bump((n) => n + 1), []);
41
44
  useEffect(() => {
42
- setActiveTheme(getTheme(config.workdir));
45
+ setActiveTheme(getTheme(cfg.workdir));
43
46
  bumpFrame();
44
- }, [config.workdir, bumpFrame]);
47
+ }, [cfg.workdir, bumpFrame]);
45
48
  const [state, setState] = useState(initialState);
46
49
  const [input, setInput] = useState(initialTask ?? "");
47
50
  /** Rows scrolled up from the bottom; 0 = follow the newest content. */
48
51
  const [scrollOffset, setScrollOffset] = useState(0);
49
- const [permMode, setPermMode] = useState(config.permissionMode);
52
+ const [permMode, setPermMode] = useState(cfg.permissionMode);
50
53
  // T-03: active permission prompt (modal) — the run blocks until answered.
51
54
  const [permPrompt, setPermPrompt] = useState(null);
52
55
  const permPromptRef = useRef(permPrompt);
53
56
  permPromptRef.current = permPrompt;
54
57
  // Session picker modal state (open via Ctrl+S)
55
58
  const [sessionPickerOpen, setSessionPickerOpen] = useState(false);
59
+ const [paletteOpen, setPaletteOpen] = useState(false);
60
+ const [helpOpen, setHelpOpen] = useState(false);
56
61
  const inputRef = useRef(initialTask ?? "");
57
62
  const writeInput = useCallback((v) => {
58
63
  inputRef.current = v;
@@ -113,14 +118,14 @@ export function TuiApp({ config, initialTask }) {
113
118
  process.stdout.write(MOUSE_DISABLE);
114
119
  };
115
120
  }, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
116
- const skills = useMemo(() => discoverSkills(config.workdir), [config.workdir]);
121
+ const skills = useMemo(() => discoverSkills(cfg.workdir), [cfg.workdir]);
117
122
  const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
118
123
  // T-02: completion sources (agents + files) computed once per mount.
119
124
  const atSources = useMemo(() => ({
120
125
  skills: skillNames,
121
- agents: discoverAgents(config.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name),
122
- files: fileIndex(config.workdir),
123
- }), [config.workdir, skillNames]);
126
+ agents: discoverAgents(cfg.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name),
127
+ files: fileIndex(cfg.workdir),
128
+ }), [cfg.workdir, skillNames]);
124
129
  // Mirror of state for the submit path (isFirst, /cost, /model) and the
125
130
  // abort handler — kept in sync synchronously by update() below, so it is
126
131
  // exact even between renders (event bursts land between them).
@@ -156,6 +161,17 @@ export function TuiApp({ config, initialTask }) {
156
161
  return { ...next, items: [...next.items] };
157
162
  });
158
163
  }, [update]);
164
+ // Realtime tick: re-render at least once per second while a run is active so
165
+ // sidebar metrics (elapsed time, ctx pressure, tok/s during/after a stream)
166
+ // update live instead of only on the next event/command boundary.
167
+ useEffect(() => {
168
+ const id = setInterval(() => {
169
+ if (!runningRef.current)
170
+ return;
171
+ update((s) => ({ ...s }));
172
+ }, 1000);
173
+ return () => clearInterval(id);
174
+ }, [update]);
159
175
  const queueEvent = useCallback((ev) => {
160
176
  // T-03: permission modal must appear instantly (not batched) — the run
161
177
  // blocks on the answer.
@@ -181,7 +197,7 @@ export function TuiApp({ config, initialTask }) {
181
197
  pushNotice("agent is running — wait or Esc first", "warn");
182
198
  return;
183
199
  }
184
- const s = resolveSessionId(config.workdir, session.id);
200
+ const s = resolveSessionId(cfg.workdir, session.id);
185
201
  if (!s) {
186
202
  pushNotice(`session not found: ${session.id}`, "error");
187
203
  return;
@@ -195,9 +211,9 @@ export function TuiApp({ config, initialTask }) {
195
211
  ? { kind: "user", text: typeof m.content === "string" ? m.content : "" }
196
212
  : { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false }),
197
213
  step: s.steps ?? 0,
198
- maxSteps: s.maxSteps ?? config.maxSteps,
199
- model: s.model || config.model,
200
- provider: s.provider || config.provider,
214
+ maxSteps: s.maxSteps ?? cfg.maxSteps,
215
+ model: s.model || cfg.model,
216
+ provider: s.provider || cfg.provider,
201
217
  status: "idle",
202
218
  summary: null,
203
219
  }));
@@ -212,11 +228,12 @@ export function TuiApp({ config, initialTask }) {
212
228
  runningRef.current = false;
213
229
  pushNotice(`resume failed: ${err.message}`, "error");
214
230
  }
215
- }, [config, createAgent, pushNotice, update]);
231
+ }, [cfg, createAgent, pushNotice, update]);
216
232
  /** T1: CommandHost cho registry dùng chung với REPL. */
217
233
  const tuiHost = useCallback(() => ({
218
234
  agent: () => agentRef.current,
219
- config,
235
+ config: cfg,
236
+ updateConfig: (patch) => setCfg((c) => ({ ...c, ...patch })),
220
237
  isRunning: () => runningRef.current,
221
238
  replaceAgent: (next) => {
222
239
  agentRef.current?.abort();
@@ -228,7 +245,7 @@ export function TuiApp({ config, initialTask }) {
228
245
  eventQueue.current = [];
229
246
  update(() => initialState());
230
247
  }
231
- setPermMode(config.permissionMode);
248
+ setPermMode(cfg.permissionMode);
232
249
  },
233
250
  resumeAndRun: async (s) => {
234
251
  const agent = createAgent();
@@ -249,9 +266,24 @@ export function TuiApp({ config, initialTask }) {
249
266
  runningRef.current = false;
250
267
  pushNotice(`resume failed: ${err.message}`, "error");
251
268
  }
252
- return { success: true, steps: 0, buildCmd: config.buildCmd, summary: "resumed" };
269
+ return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
253
270
  },
254
- }), [config, update, pushNotice, createAgent]);
271
+ /** [Phase 7] Transcript items — used by /export and /search. */
272
+ transcriptItems: () => stateRef.current.items,
273
+ /** [Phase 7] Scroll to a transcript item by index (best-effort row estimation). */
274
+ selectTranscriptItem: (index) => {
275
+ const items = stateRef.current.items;
276
+ if (index < 0 || index >= items.length)
277
+ return;
278
+ // target: show item at index at the TOP of the viewport.
279
+ // Ink contentOffsetY shifts content upward by that many rows.
280
+ let above = 0;
281
+ for (let i = 0; i < index; i++)
282
+ above += estimateItemRows(items[i]);
283
+ const maxScroll = Math.max(0, content.height - viewport.height);
284
+ setScrollOffset(Math.max(0, maxScroll - above));
285
+ },
286
+ }), [cfg, update, pushNotice, createAgent, viewport, content]);
255
287
  /** One round of the /review state machine: call the gate, render the result. */
256
288
  const reviewStep = useCallback(async (tool, args) => {
257
289
  const agent = agentRef.current ?? createAgent();
@@ -310,7 +342,7 @@ export function TuiApp({ config, initialTask }) {
310
342
  writeInput("");
311
343
  }, [closeStreamingItems, writeInput]);
312
344
  const submit = useCallback(async (text) => {
313
- const task = text.trim();
345
+ let task = text.trim();
314
346
  if (!task)
315
347
  return;
316
348
  // Prompt-gate review in progress — numeric replies drive the flow.
@@ -336,17 +368,21 @@ export function TuiApp({ config, initialTask }) {
336
368
  pushNotice("usage: /review <prompt> — rewrites it, you pick original/rewritten/update/cancel", "warn");
337
369
  return;
338
370
  }
339
- if (!Object.keys(config.mcpServers ?? {}).some((s) => s === "prompt-gate")) {
340
- pushNotice("no \"prompt-gate\" MCP server configured — add it to ~/.scoder/config.json", "error");
371
+ // v2: no hardcoded server/language — any MCP server exposing a
372
+ // review_and_select_prompt tool wins; language is auto-detected by
373
+ // the gate from the prompt itself.
374
+ const servers = Object.keys(cfg.mcpServers ?? {});
375
+ const gateServer = servers.find((s) => s === "prompt-gate") ?? servers.find((s) => /gate/i.test(s));
376
+ if (!gateServer) {
377
+ pushNotice("no MCP prompt-gate server configured — add one exposing review_and_select_prompt to ~/.scoder/config.json", "error");
341
378
  return;
342
379
  }
343
380
  // Context grounded in this session: last user item + current workdir.
344
381
  const lastUser = [...stateRef.current.items].reverse().find((i) => i.kind === "user");
345
- const context = `User is in scoder TUI at ${config.workdir}${lastUser ? `; earlier they asked: "${lastUser.text.slice(0, 120)}"` : ""}.`;
346
- void reviewStep("mcp__prompt-gate__review_and_select_prompt", {
382
+ const context = `User is in scoder TUI at ${cfg.workdir}${lastUser ? `; earlier they asked: "${lastUser.text.slice(0, 120)}"` : ""}.`;
383
+ void reviewStep(`mcp__${gateServer}__review_and_select_prompt`, {
347
384
  prompt: slash.arg,
348
385
  context,
349
- language: "vietnamese",
350
386
  });
351
387
  return;
352
388
  }
@@ -453,7 +489,12 @@ export function TuiApp({ config, initialTask }) {
453
489
  agentRef.current?.abort();
454
490
  exit();
455
491
  }
456
- return;
492
+ if (handled && outcome?.task) {
493
+ task = outcome.task;
494
+ }
495
+ else {
496
+ return;
497
+ }
457
498
  }
458
499
  if (runningRef.current) {
459
500
  // Steer: user input while the agent runs — deliver at next step
@@ -485,12 +526,12 @@ export function TuiApp({ config, initialTask }) {
485
526
  update((s) => applyEvent({ ...s, items: [...s.items] }, { type: "finish", success: false, steps: s.step, summary: `Run failed: ${err.message}` }));
486
527
  update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: err.message, level: "error" }] }));
487
528
  }
488
- }, [config, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, reviewAnswer, reviewStep, tuiHost, preSubmit]);
529
+ }, [cfg, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, reviewAnswer, reviewStep, tuiHost, preSubmit]);
489
530
  /** Create (or return) the session agent — permission mode always current. */
490
531
  function createAgent() {
491
532
  if (agentRef.current)
492
533
  return agentRef.current;
493
- const agent = new Agent({ ...config, permissionMode: permMode });
534
+ const agent = new Agent({ ...cfg, permissionMode: permMode });
494
535
  // Events go through the batching queue (see FLUSH_MS above): applyEvent
495
536
  // still receives the latest state because update() keeps stateRef in
496
537
  // sync synchronously — tool results can never miss their items.
@@ -526,6 +567,16 @@ export function TuiApp({ config, initialTask }) {
526
567
  }
527
568
  return;
528
569
  }
570
+ // ── Ctrl+P: command palette ──
571
+ if (key.ctrl && _input === "p") {
572
+ setPaletteOpen((o) => !o);
573
+ return;
574
+ }
575
+ // ── ?: help overlay (when input empty) ──
576
+ if (_input === "?" && !inputRef.current.trim()) {
577
+ setHelpOpen((o) => !o);
578
+ return;
579
+ }
529
580
  // T-10: ctrl+t toggles the thinking stream visibility.
530
581
  if (key.ctrl && _input === "t") {
531
582
  update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
@@ -598,7 +649,7 @@ export function TuiApp({ config, initialTask }) {
598
649
  // flexShrink={0}: pinned chrome must never be squeezed when the chat
599
650
  // content overflows — Ink defaults every box to flexShrink:1, which
600
651
  // shrank the input bar to zero rows once the log outgrew the terminal.
601
- _jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: config }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: scrollTop, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" }), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { dimColor: true, children: ["\u23CE send \u00B7 ", _jsx(Text, { bold: true, children: "ctrl+s" }), " sessions \u00B7 tab complete \u00B7 /help \u00B7 /skills"] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: "green", bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: "red", bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: config })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: config.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 1, paddingBottom: 1, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: config, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
652
+ _jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: scrollTop, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" }), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { dimColor: true, children: ["\u23CE send \u00B7 ", _jsx(Text, { bold: true, children: "ctrl+s" }), " sessions \u00B7 tab complete \u00B7 /help \u00B7 /skills"] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? 42 : 4) }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: "green", bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: "red", bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 1, paddingBottom: 1, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
602
653
  }
603
654
  /** Mount the TUI (called from the `scoder tui` subcommand). */
604
655
  export async function runTui(config, initialTask) {
@@ -0,0 +1,25 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { theme } from "./theme.js";
5
+ /** Modal for ask_user tool — single/multi choice + free text. */
6
+ export function AskUserModal({ question, options, multiple, onAnswer, onCancel }) {
7
+ const [input, setInput] = useState("");
8
+ useInput((ch, key) => {
9
+ if (key.escape) {
10
+ onCancel();
11
+ return;
12
+ }
13
+ if (key.return) {
14
+ onAnswer(input.trim() || (options?.[0] ?? ""));
15
+ return;
16
+ }
17
+ if (key.backspace) {
18
+ setInput((v) => v.slice(0, -1));
19
+ return;
20
+ }
21
+ if (ch && !key.ctrl && !key.meta)
22
+ setInput((v) => v + ch);
23
+ });
24
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["\u2753 ", question] }), options?.length ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: options.map((o, i) => _jsxs(Text, { children: [i + 1, ". ", o] }, i)) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [multiple ? "comma-separated choices" : "type answer", ": "] }), _jsx(Text, { children: input }), _jsx(Text, { color: theme.accent, children: "\u258C" })] }), _jsx(Text, { dimColor: true, children: "Enter submit \u00B7 Esc cancel" })] }));
25
+ }
@@ -53,8 +53,9 @@ function previewLines(output) {
53
53
  .filter((l) => !/^--- (stdout|stderr) ---$/.test(l.trim()));
54
54
  return lines.slice(-OUTPUT_PREVIEW_LINES);
55
55
  }
56
- function ToolItemView({ item }) {
56
+ function ToolItemView({ item, width }) {
57
57
  const running = item.ok === null;
58
+ const outputWidth = Math.max(24, (width ?? 100) - 6);
58
59
  // One meaningful arg per tool type keeps the line scannable.
59
60
  const keyArg = typeof item.args.command === "string"
60
61
  ? fit(item.args.command, 60)
@@ -75,7 +76,7 @@ function ToolItemView({ item }) {
75
76
  ? `${(elapsedMs / 1000).toFixed(0)}s`
76
77
  : `${(elapsedMs / 1000).toFixed(1)}s`
77
78
  : null;
78
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8 "] }), _jsxs(Text, { color: theme.accent, children: [toolIcon(item.name), " "] }), _jsx(Text, { color: item.ok === false ? theme.error : theme.accent, bold: true, children: item.name }), keyArg && _jsxs(Text, { dimColor: true, children: [" ", keyArg] }), running && _jsx(Text, { dimColor: true, children: " \u00B7 running" }), !running && elapsed && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", elapsed] })] }), preview.map((line, i) => (_jsxs(Text, { dimColor: true, children: [" └ ", line.length > 140 ? line.slice(0, 140) + "…" : line] }, i))), halfblocks && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [halfblocks.map((r, i) => (_jsx(Text, { children: r.text }, i))), _jsx(Text, { dimColor: true, children: " \u2514 image preview" })] })), item.image && !halfblocks && (_jsxs(Text, { dimColor: true, children: [" \u2514 image \u2014 ", item.output] }))] }));
79
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8 "] }), _jsxs(Text, { color: theme.accent, children: [toolIcon(item.name), " "] }), _jsx(Text, { color: item.ok === false ? theme.error : theme.accent, bold: true, children: item.name }), keyArg && _jsxs(Text, { dimColor: true, children: [" ", keyArg] }), running && _jsx(Text, { dimColor: true, children: " \u00B7 running" }), !running && elapsed && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", elapsed] })] }), preview.map((line, i) => (_jsxs(Text, { dimColor: true, children: [" └ ", line.length > outputWidth ? line.slice(0, outputWidth - 1) + "…" : line] }, i))), halfblocks && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [halfblocks.map((r, i) => (_jsx(Text, { children: r.text }, i))), _jsx(Text, { dimColor: true, children: " \u2514 image preview" })] })), item.image && !halfblocks && (_jsxs(Text, { dimColor: true, children: [" \u2514 image \u2014 ", item.output] }))] }));
79
80
  }
80
81
  /** One colored line of a unified diff (+ green, - red, @@ cyan, header bold). */
81
82
  export function DiffLine({ line }) {
@@ -95,13 +96,14 @@ const MAX_DIFF_RENDER_LINES = 20;
95
96
  * Header row: [step] ✎ [+/− stats] [path]
96
97
  * Body: up to MAX_DIFF_RENDER_LINES of colored diff lines.
97
98
  */
98
- function DiffItemView({ item }) {
99
+ function DiffItemView({ item, width }) {
99
100
  const lines = item.text.length > 0 ? item.text.split("\n") : [];
100
101
  const shown = lines.slice(0, MAX_DIFF_RENDER_LINES);
101
102
  const hidden = lines.length - shown.length;
103
+ const diffWidth = Math.max(24, (width ?? 100) - 6);
102
104
  const addedColor = item.added > 0 ? theme.success : undefined;
103
105
  const removedColor = item.removed > 0 ? theme.error : undefined;
104
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, marginLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8 "] }), _jsx(Text, { color: theme.brand, bold: true, children: "\u270E" }), _jsx(Text, { children: " " }), _jsxs(Text, { color: addedColor, children: ["+", item.added] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: removedColor, children: ["-", item.removed] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: theme.accent, children: item.path }), item.truncated && _jsx(Text, { dimColor: true, children: " \u00B7 diff truncated" })] }), shown.map((line, i) => (_jsx(DiffLine, { line: line.length > 160 ? line.slice(0, 160) + "…" : line }, i))), hidden > 0 && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", hidden, " more diff lines"] }))] }));
106
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, marginLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8 "] }), _jsx(Text, { color: theme.brand, bold: true, children: "\u270E" }), _jsx(Text, { children: " " }), _jsxs(Text, { color: addedColor, children: ["+", item.added] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: removedColor, children: ["-", item.removed] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: theme.accent, children: item.path }), item.truncated && _jsx(Text, { dimColor: true, children: " \u00B7 diff truncated" })] }), shown.map((line, i) => (_jsx(DiffLine, { line: line.length > diffWidth ? line.slice(0, diffWidth - 1) + "…" : line }, i))), hidden > 0 && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", hidden, " more diff lines"] }))] }));
105
107
  }
106
108
  /**
107
109
  * Plan KHÔNG còn render trong chat — checklist live hiển thị ở Sidebar bên
@@ -111,7 +113,7 @@ function DiffItemView({ item }) {
111
113
  function TodoItemView() {
112
114
  return null;
113
115
  }
114
- export function ChatItemView({ item }) {
116
+ export function ChatItemView({ item, width }) {
115
117
  switch (item.kind) {
116
118
  case "user":
117
119
  // User messages stand out with a full colored border + bold label so they're
@@ -122,15 +124,15 @@ export function ChatItemView({ item }) {
122
124
  if (item.streaming || !item.text) {
123
125
  return (_jsxs(Box, { marginTop: item.text ? 1 : 0, paddingLeft: 2, children: [_jsx(AgentMark, {}), _jsx(Text, { dimColor: item.streaming, children: item.text || (item.streaming ? "" : "(no text)") }), item.streaming && _jsx(Text, { color: theme.accent, bold: true, children: "\u258B" })] }));
124
126
  }
125
- return (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(AgentMark, {}), _jsx(Markdown, { text: item.text })] }));
127
+ return (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(AgentMark, {}), _jsx(Markdown, { text: item.text, width: width })] }));
126
128
  }
127
129
  case "thinking":
128
130
  // T-10: reasoning stream — dim color, ⊙ prefix, collapse to last 4 lines.
129
131
  return (_jsx(Box, { paddingLeft: 1, marginTop: 1, children: _jsxs(Text, { color: theme.thinking, italic: true, children: [_jsx(Text, { dimColor: true, children: "\u2299" }), " ", item.text.split("\n").slice(-4).join("\n ")] }) }));
130
132
  case "tool":
131
- return _jsx(ToolItemView, { item: item });
133
+ return _jsx(ToolItemView, { item: item, width: width });
132
134
  case "diff":
133
- return _jsx(DiffItemView, { item: item });
135
+ return _jsx(DiffItemView, { item: item, width: width });
134
136
  case "todo":
135
137
  return _jsx(TodoItemView, {});
136
138
  case "build":
@@ -138,6 +140,11 @@ export function ChatItemView({ item }) {
138
140
  return (_jsx(Box, { paddingLeft: 1, marginTop: 1, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: item.success
139
141
  ? _jsx(Text, { color: theme.success, bold: true, children: "\u2713 build passed" })
140
142
  : _jsx(Text, { color: theme.error, bold: true, children: "\u2717 build FAILED" }) }), _jsxs(Text, { dimColor: true, children: [" ", item.command] })] }) }));
143
+ case "compaction": {
144
+ // Divider marking an LLM summarization of older history.
145
+ const fmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
146
+ return (_jsx(Box, { paddingLeft: 1, marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: ["\u2500\u2500 ", item.manual ? "compacted (manual)" : "compacted", ": ", fmt(item.tokensBefore), " \u2192 ", fmt(item.tokensAfter), " tokens \u2500\u2500\u2500"] }) }));
147
+ }
141
148
  case "notice":
142
149
  return (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsxs(Text, { color: item.level === "error" ? theme.error :
143
150
  item.level === "warn" ? theme.warn :