s-app-scoder-agent 0.9.9 → 0.9.10

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.
@@ -167,6 +167,23 @@ const continueNudgeRule = { name: "continue-nudge",
167
167
  return null;
168
168
  if (claimsCompletion(ctx.text))
169
169
  return null; // completion-claim rule phụ trách
170
+ // P0-8: text-only + không sửa file + không có plan mở ⇒ đây là LƯỢT CHAT.
171
+ // Finish ngay với chính text làm summary — trước đây rule này inject nudge
172
+ // ⇒ thêm 1 LLM call ~14,5k token mỗi câu chào (đo: call 2 in=14.569, chỉ
173
+ // thêm đúng dòng nudge 81 token). Bài học aec95ef giữ nguyên: nhánh CÓ
174
+ // sửa file vẫn nudge/ép build bên dưới; plan còn mở (model nghỉ giữa
175
+ // task, quên gọi tool) cũng vẫn nudge.
176
+ if (!ctx.filesModified && ctx.planOpen !== true) {
177
+ return {
178
+ action: {
179
+ kind: "finish",
180
+ success: true,
181
+ summary: ctx.text || "(agent replied without acting)",
182
+ chatOnly: true,
183
+ },
184
+ reason: "chat turn: tool-less step, no files modified, no open plan — one LLM call per message",
185
+ };
186
+ }
170
187
  // No file changed yet → never demand a build for what may be a pure Q&A or
171
188
  // an explanation-only answer. Give the model the explicit conversational
172
189
  // exit instead; text-only-streak still ends the run if it keeps stalling.
@@ -19,7 +19,12 @@ export function buildRunMessages(input) {
19
19
  const { config, primaryDef, skills, buildCmd, task, pendingContext } = input;
20
20
  const skillsBlock = buildSkillsBlock(skills);
21
21
  const agentsGuideBlock = buildAgentGuideBlock(config.workdir, task);
22
- const repoMapBlock = config.repoMap === false ? "" : `\n\n${buildRepoMap(config.workdir)}`;
22
+ // P0-10: REPO MAP rời khỏi system prompt. Map mô tả TRẠNG THÁI repo — mỗi
23
+ // byte đổi (file mới, tên symbol đổi) làm lệch prefix system+tools ở token
24
+ // ~954 ⇒ mọi cửa sổ mới mất 96% KV-cache (đo: cached 14.484 → 0, TTFT
25
+ // 0,2 s → 30–47 s). Đặt làm message sát task: prefix system+tools+junk byte
26
+ // stable giữa các cửa sổ, map (cold) chỉ còn là đuôi ~2k token.
27
+ const repoMapBlock = config.repoMap === false ? "" : buildRepoMap(config.workdir);
23
28
  const baseOverride = primaryDef?.prompt?.trim() ? primaryDef.prompt : undefined;
24
29
  return [
25
30
  {
@@ -31,12 +36,20 @@ export function buildRunMessages(input) {
31
36
  skillsBlock,
32
37
  mcpBlock: buildMcpBlock(config),
33
38
  agentsGuideBlock,
34
- repoMapBlock,
39
+ repoMapBlock: "",
35
40
  base: baseOverride,
36
41
  promptVersion: config.promptVersion,
37
42
  }) + buildAgentsBlock(discoverAgents(config.workdir), config.allowSubagents !== false),
38
43
  },
39
44
  ...pendingContext.map((content) => ({ role: "user", content })),
45
+ ...(repoMapBlock
46
+ ? [{
47
+ role: "user",
48
+ content: "[Context — repository map as of session start; paths + exported symbols only. " +
49
+ "If a file looks missing or stale, confirm with list_files/glob/repo_map rather than assume.]\n\n" +
50
+ repoMapBlock,
51
+ }]
52
+ : []),
40
53
  { role: "user", content: task },
41
54
  ];
42
55
  }
@@ -246,6 +246,9 @@ export async function runAgentLoop(runtime, startSteps, buildCmd, initialBuild =
246
246
  buildCallPresent: !!buildCall,
247
247
  filesModified: runtime.filesModified,
248
248
  chatDone,
249
+ // P0-8: plan chưa xong ⇒ bước text-only chỉ là nghỉ giữa task (nudge);
250
+ // plan trống/đủ ⇒ rule coi đó là lượt chat và finish (1 call).
251
+ planOpen: runtime.plan.some((t) => t.status !== "completed"),
249
252
  outputTokensThisStep: usage?.completion_tokens,
250
253
  totalTokensIn: runtime.usageTotals.inputTokens, // G6 budget rule
251
254
  totalTokensOut: runtime.usageTotals.outputTokens,
@@ -104,19 +104,21 @@ export function extractSymbols(path, content) {
104
104
  return [...out].slice(0, MAX_SYMBOLS_PER_FILE);
105
105
  }
106
106
  // ── Rendering ────────────────────────────────────────────────────────────────
107
- function kb(size) {
108
- return size >= 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(1)}MB` : `${Math.max(1, Math.round(size / 1024))}KB`;
109
- }
110
- /** Render the map without touching the cache (used for sub-tree maps). */
107
+ /** Render the map without touching the cache (used for sub-tree maps).
108
+ * P0-10: deliberately NO per-file sizes and NO file-count header — those bytes
109
+ * change on every edit/add and used to invalidate the KV-cache prefix of every
110
+ * fresh window (measured: 1 char in REPO MAP ⇒ cached=0, TTFT 30–47 s). Symbol
111
+ * names still change only when a file's exported API changes, so the map is
112
+ * byte-stable across normal coding. */
111
113
  export function renderRepoMap(workdir, budgetChars = DEFAULT_BUDGET_CHARS, sub = ".") {
112
114
  const files = walkRepo(workdir, sub);
113
115
  const lines = [
114
- `REPO MAP — ${files.length} files (auto-generated; orient from this, don't read everything)`,
116
+ "REPO MAP — auto-generated; orient from this, don't read everything",
115
117
  ];
116
118
  let budget = budgetChars;
117
119
  let truncated = 0;
118
120
  for (const f of files) {
119
- let line = ` ${f.rel} (${kb(f.size)})`;
121
+ let line = ` ${f.rel}`;
120
122
  if (SYMBOL_FILE.test(f.rel) && f.size <= MAX_FILE_BYTES) {
121
123
  try {
122
124
  const syms = extractSymbols(f.rel, readFileSync(f.abs, "utf8"));
package/dist/tui/app.js CHANGED
@@ -19,6 +19,7 @@ import { setQuiet } from "../io/terminal.js";
19
19
  import { discoverSkills } from "../context/skills.js";
20
20
  import { discoverAgents } from "../context/agents.js";
21
21
  import { initialState } from "./state.js";
22
+ import { transcriptWindow, estimateTotalRows } from "./selectors.js";
22
23
  import { SLASH_COMMANDS, formatSlashHelp } from "../commands/definitions.js";
23
24
  import { ChatItemView } from "./chat-views.js"; // T2
24
25
  import { BuildRail, StatusBar, SIDEBAR_MIN_COLS, SIDEBAR_WIDTH } from "./chrome.js"; // T2
@@ -95,13 +96,25 @@ export function TuiApp({ config, initialTask }) {
95
96
  agentRef.current?.dispose();
96
97
  agentRef.current = null;
97
98
  }, []);
98
- // ── Scrollable chat viewport (Ink 8 alt-screen) ──
99
- // The viewport clips overflow; the inner content wrapper keeps its natural
100
- // height so it can be measured and offset upward for scrolling.
101
- const { viewportRef, contentRef, viewport, content, viewportRowsRef } = useScrollMetrics(setScrollOffset);
102
- const maxScroll = Math.max(0, content.height - viewport.clientHeight);
103
- // scrollOffset counts rows UP from the bottom; 0 = follow the newest item.
104
- const scrollTop = Math.max(0, maxScroll - scrollOffset);
99
+ // ── Scrollable chat viewport (Ink 8 alt-screen) — P1-8 virtualized ──
100
+ // Chi phí layout/wrap mỗi frame tỷ lệ với số item MOUNT, không phải số
101
+ // item transcript ⇒ chỉ mount cửa sổ quanh viewport (transcriptWindow).
102
+ // Scroll offset khi đọc lại dùng rows ước lượng; khi BÁM ĐÁY dùng chiều
103
+ // cao THẬT của cửa sổ đã mount (đo rẻ vì cửa sổ ~viewport+overscan) —
104
+ // estimate sai lệch bao nhiêu thì dòng cuối vẫn luôn thấy.
105
+ const { viewportRef, viewport, contentRef, content, viewportRowsRef } = useScrollMetrics(setScrollOffset);
106
+ const viewportRows = viewport.clientHeight || 24;
107
+ const win = useMemo(() => {
108
+ const total = estimateTotalRows(state.items);
109
+ const max = Math.max(0, total - viewportRows);
110
+ const top = Math.max(0, max - scrollOffset);
111
+ return { ...transcriptWindow(state.items, top, viewportRows), maxScroll: max };
112
+ }, [state.items, scrollOffset, viewportRows]);
113
+ const maxScroll = win.maxScroll;
114
+ // Follow-bottom (scrollOffset = 0): offset chính xác theo số đo thật.
115
+ const contentOffset = scrollOffset === 0 && content.hasMeasured
116
+ ? Math.max(0, content.height - viewport.clientHeight)
117
+ : win.offset;
105
118
  const skills = useMemo(() => discoverSkills(cfg.workdir), [cfg.workdir]);
106
119
  const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
107
120
  // T-02: completion sources (agents + files) computed once per mount.
@@ -164,19 +177,20 @@ export function TuiApp({ config, initialTask }) {
164
177
  setScrollOffset(0);
165
178
  prevStatusRef.current = state.status;
166
179
  }, [state.status]);
167
- // The viewport renders items at full height so scroll math (content.height) is
168
- // always correct. Ink clips with overflow="hidden"; contentOffsetY shifts the
169
- // content column upward to reveal items above the current bottom.
180
+ // P1-8: viewport mount CỬA SỔ item quanh scrollTop (transcriptWindow), không
181
+ // mount cả transcript — chi phí layout/wrap mỗi frame hết tỷ lệ với tổng text.
182
+ // Ink clip bằng overflow="hidden"; contentOffsetY = số dòng phải ẩn ở đầu
183
+ // cửa sổ (scrollTop - startRow[item_start]).
170
184
  const { rows, columns } = useWindowSize();
171
185
  const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
172
186
  return (
173
187
  // flexShrink={0}: pinned chrome must never be squeezed when the chat
174
188
  // content overflows — Ink defaults every box to flexShrink:1, which
175
189
  // shrank the input bar to zero rows once the log outgrew the terminal.
176
- _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 ? (
190
+ _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: contentOffset, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (
177
191
  // Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
178
192
  // viewport (flexGrow không giãn được vì content box flexShrink=0).
179
- _jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_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 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), 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: boxBorderStyle(), 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: 0, paddingBottom: 0, 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 })] }));
193
+ _jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_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 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] })] })) : (state.items.slice(win.start, win.end).map((item, i) => (_jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, win.start + i)))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), 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: boxBorderStyle(), 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: 0, paddingBottom: 0, 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 })] }));
180
194
  }
181
195
  /** Mount the TUI (called from the `scoder tui` subcommand). */
182
196
  export async function runTui(config, initialTask) {
@@ -1,4 +1,7 @@
1
- /** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI. */
1
+ /** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI.
2
+ * P1-8: contentRef giờ đo CỬA SỔ item đã mount (không phải cả transcript) —
3
+ * chi phí đo O(window) nên vẫn rẻ; số đo thật dùng cho follow-bottom chính
4
+ * xác, ước lượng (transcriptWindow) chỉ dùng khi scroll lên trên. */
2
5
  import { useCallback, useEffect, useRef } from "react";
3
6
  import { useBoxMetrics, useStdin } from "ink";
4
7
  import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "../mouse.js";
@@ -34,5 +37,5 @@ export function useScrollMetrics(setScrollOffset) {
34
37
  process.stdout.write(MOUSE_DISABLE);
35
38
  };
36
39
  }, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
37
- return { viewportRef, contentRef, viewport, content, viewportRowsRef };
40
+ return { viewportRef, viewport, contentRef, content, viewportRowsRef };
38
41
  }
@@ -70,11 +70,13 @@ export function estimateMarkdownRows(text, width = EST_WIDTH) {
70
70
  export function estimateItemRows(item) {
71
71
  switch (item.kind) {
72
72
  case "user":
73
- return 2 + Math.ceil(item.text.length / 80);
73
+ // Render: marginTop 1 + border-left box paddingX 1, wrap theo chat width
74
+ // (~60–96 cột). 60 là giả định hẹp an toàn (over-estimate nhẹ).
75
+ return 2 + wrapEstimate(item.text ?? "", 60);
74
76
  case "assistant":
75
77
  // +1 marginTop; streaming raw text dùng wrap đơn giản (chưa qua markdown).
76
78
  return item.streaming
77
- ? wrapEstimate(item.text ?? "", EST_WIDTH)
79
+ ? (item.text ? 1 : 0) + wrapEstimate(item.text ?? "", 76)
78
80
  : 1 + estimateMarkdownRows(item.text ?? "", EST_WIDTH);
79
81
  case "thinking":
80
82
  // Render: collapse to last 4 lines.
@@ -82,7 +84,7 @@ export function estimateItemRows(item) {
82
84
  case "tool":
83
85
  return 1 + 2; // title + preview 2 lines
84
86
  case "compaction":
85
- return 1;
87
+ return 2; // marginTop + divider
86
88
  case "diff":
87
89
  return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
88
90
  case "todo":
@@ -90,15 +92,19 @@ export function estimateItemRows(item) {
90
92
  // chiếm 0 dòng thực tế trong transcript.
91
93
  return 0;
92
94
  case "build":
93
- return 1;
95
+ return 3; // marginTop + label + command
94
96
  case "notice":
95
- return 1 + Math.ceil(item.text.length / 2000);
97
+ // Render raw text (cap 2000 chars) với wrap thật theo width — /help là
98
+ // notice 40 dòng; estimate cũ (1 + len/2000) under-estimate khiến
99
+ // follow-bottom clip dòng kế tiếp ( regression caught by tui.test E2E).
100
+ return 1 + wrapEstimate(item.text.length > 2000 ? item.text.slice(0, 2000) + "…" : item.text, 76);
96
101
  }
97
102
  }
98
103
  /**
99
104
  * T4: chọn window item để render: lấy từ cuối lên tới khi tổng rows ước
100
105
  * lượng ≥ viewportRows + margin (render dư phía trên để scroll mượt), nhưng
101
- * giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không mutate).
106
+ * giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không rẻ — dùng
107
+ * transcriptWindow cho scroll đúng offset, hàm này chỉ còn cho callers cũ).
102
108
  */
103
109
  export function visibleWindow(items, viewportRows, margin = 50) {
104
110
  const budget = viewportRows + margin;
@@ -110,6 +116,55 @@ export function visibleWindow(items, viewportRows, margin = 50) {
110
116
  }
111
117
  return start === 0 ? items : items.slice(start);
112
118
  }
119
+ // P1-8: cache số dòng ước lượng theo IDENTITY của item. state.ts patch item
120
+ // kiểu immutable (object mới khi nội dung đổi) ⇒ item finalized không bao giờ
121
+ // tính lại; chỉ item đang stream (mỗi flush đổi reference) mới estimate lại.
122
+ const itemRowsCache = new WeakMap();
123
+ function itemRowsCached(item) {
124
+ const hit = itemRowsCache.get(item);
125
+ if (hit !== undefined)
126
+ return hit;
127
+ const v = estimateItemRows(item);
128
+ itemRowsCache.set(item, v);
129
+ return v;
130
+ }
131
+ /** Tổng chiều cao ước lượng của cả transcript — O(n) phép cộng số nhỏ. */
132
+ export function estimateTotalRows(items) {
133
+ let acc = 0;
134
+ for (const it of items)
135
+ acc += itemRowsCached(it);
136
+ return acc;
137
+ }
138
+ /**
139
+ * P1-8 — virtualization cho transcript viewport. Mount duy các item phủ
140
+ * [scrollTop - overscan, scrollTop + viewportRows + overscan] thay vì cả
141
+ * transcript: chi phí React diff + Yoga layout + wrap mỗi frame hết tỷ lệ
142
+ * với tổng số text (đo: 42–53 ms/frame ở 300–400 item → ~7 ms khi window).
143
+ * Scroll dùng ước lượng rows (estimateItemRows, cache theo identity item),
144
+ * nên khi estimate lệch thực tế thì offset chỉ sai vài dòng — cùng trade-off
145
+ * mọi TUI virtualized dùng.
146
+ */
147
+ export function transcriptWindow(items, scrollTop, viewportRows, overscan = 24) {
148
+ const n = items.length;
149
+ // Cumulative start rows một lượt O(n) nhưng chỉ là phép cộng số nhỏ.
150
+ const startRow = new Array(n + 1);
151
+ startRow[0] = 0;
152
+ for (let i = 0; i < n; i++)
153
+ startRow[i + 1] = startRow[i] + itemRowsCached(items[i]);
154
+ const totalRows = startRow[n];
155
+ const top = Math.max(0, Math.min(scrollTop, totalRows));
156
+ const target = Math.max(0, top - overscan);
157
+ let start = 0;
158
+ while (start + 1 < n && startRow[start + 1] <= target)
159
+ start++;
160
+ const bottomTarget = top + viewportRows + overscan;
161
+ let end = start;
162
+ while (end < n && startRow[end] < bottomTarget)
163
+ end++;
164
+ if (end === start && start < n)
165
+ end = start + 1; // item khổng lồ vẫn phải hiện
166
+ return { start, end, offset: top - startRow[start], totalRows };
167
+ }
113
168
  /**
114
169
  * Plan mới nhất cho sidebar — todo items được append mỗi lần update nhưng
115
170
  * sidebar chỉ cần snapshot hiện tại (last-wins), null khi chưa có plan.
package/dist/tui/state.js CHANGED
@@ -79,7 +79,7 @@ function applyGuardrail(state, ev) {
79
79
  }
80
80
  // Pure selectors live in ./selectors.js (Phase 5.4) — re-exported so existing
81
81
  // import sites (app.tsx, hooks, tests) keep working unchanged.
82
- export { estimateItemRows, visibleWindow, latestPlan, toolPhase } from "./selectors.js";
82
+ export { estimateItemRows, visibleWindow, transcriptWindow, estimateTotalRows, latestPlan, toolPhase } from "./selectors.js";
83
83
  /**
84
84
  * Fold one AgentEvent into the state (mutates `state`, returns it for
85
85
  * reducer-style chaining). Unknown events are ignored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "Autonomous coding agent CLI — writes, edits, and builds code autonomously until the project compiles without errors.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",