s-app-scoder-agent 0.9.9 → 0.9.14

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.
@@ -38,6 +38,8 @@
38
38
  "verifyCache": true,
39
39
  "localProfile": true,
40
40
 
41
+ "warmup": true,
42
+
41
43
  "mcpTools": {
42
44
  "include": [],
43
45
  "exclude": ["mcp__github-mcp__*notifications*", "mcp__github-mcp__*codespaces*"]
package/README.md CHANGED
@@ -395,7 +395,7 @@ Features:
395
395
  - **Slash commands** — `/help`, `/clear`, `/compact`, `/cost`, `/model`, `/skill`, `/mcp`, `/permission` (identical to the REPL; `/theme` is TUI-only)
396
396
  - **Multi-turn** — keep typing follow-ups after a task finishes; the conversation context carries over (same as the REPL)
397
397
  - **Scrollback** — `PgUp`/`PgDn` page in 0.8-viewport steps, `Home`/`End` jump to top/bottom, `Esc` returns to the live tail (press again to abort); rendering is capped at the last 200 items for smooth scrolling on long runs
398
- - **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+C` quit
398
+ - **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+O` toggle select mode (releases mouse tracking so you can drag-highlight chat text and copy it with `⌘C`/`Ctrl+C`) · `Ctrl+C` quit
399
399
  - Non-TTY environments (CI, pipes) automatically fall back to the classic REPL
400
400
  - `permission: "ask"` is not supported in the TUI (no readline prompts on the alt screen) — it falls back to `auto`
401
401
 
@@ -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.
@@ -21,15 +21,22 @@ export class InteractionBroker {
21
21
  async awaitQuestion(runtime, request, id, handler) {
22
22
  try {
23
23
  const timeoutMs = (request.timeoutSecs ?? 120) * 1000;
24
- const answer = await Promise.race([
25
- handler({ ...request, id }),
26
- new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
27
- ]);
28
- if (answer == null || (typeof answer === "string" && !answer.trim())) {
29
- return { ok: false, output: "ERROR: ask_user timed out or was cancelled." };
24
+ let timer;
25
+ const timeoutPromise = new Promise((resolve) => {
26
+ timer = setTimeout(() => resolve(null), timeoutMs);
27
+ });
28
+ try {
29
+ const answer = await Promise.race([handler({ ...request, id }), timeoutPromise]);
30
+ if (answer == null || (typeof answer === "string" && !answer.trim())) {
31
+ return { ok: false, output: "ERROR: ask_user timed out or was cancelled." };
32
+ }
33
+ runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
34
+ return { ok: true, output: `User answered: ${answer}` };
35
+ }
36
+ finally {
37
+ if (timer)
38
+ clearTimeout(timer);
30
39
  }
31
- runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
32
- return { ok: true, output: `User answered: ${answer}` };
33
40
  }
34
41
  catch (err) {
35
42
  return { ok: false, output: `ERROR: ask_user failed: ${err.message.slice(0, 200)}` };
@@ -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
  }
@@ -75,6 +88,11 @@ export class PromptContext {
75
88
  clearPendingTools() {
76
89
  this.pendingAllowedTools = null;
77
90
  }
91
+ /** Non-destructive read — warm-up dựng đúng messages như run() nhưng KHÔNG
92
+ * tiêu thụ pending context (nó thuộc về run thật, take là mất). */
93
+ peekPendingContext() {
94
+ return [...this.pendingContext];
95
+ }
78
96
  takePendingContext() {
79
97
  const pending = this.pendingContext;
80
98
  this.pendingContext = [];
@@ -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,
package/dist/agent.js CHANGED
@@ -1,4 +1,4 @@
1
- import { loadPermissionLists, loadPolicyRules, loadSavedApprovals } from "./config/config.js";
1
+ import { loadPermissionLists, loadPolicyRules, loadSavedApprovals, adaptiveToolOutputChars } from "./config/config.js";
2
2
  import { LlmClient, LlmError, redactSecrets } from "./llm/llm.js";
3
3
  import { AnthropicClient } from "./llm/provider-anthropic.js";
4
4
  import { allToolSpecs } from "./tools/registry.js";
@@ -33,6 +33,12 @@ import { runAgentLoop } from "./agent/run-loop.js";
33
33
  // CHAT_DONE_MARKER import từ guardrails.js (marker chat thuần, rule 9).
34
34
  import { EVENT_SCHEMA_VERSION, applyToolFilter, resolvePrimaryConfig, } from "./agent/types.js";
35
35
  export { EVENT_SCHEMA_VERSION, resolvePrimaryConfig } from "./agent/types.js";
36
+ /**
37
+ * FILE-READ-PERF P3: nhịp heartbeat re-warm khi idle — 4' < TTL cache ~5'
38
+ * (Anthropic ephemeral / Ollama keep_alive mặc định), để prefix không bao giờ
39
+ * nguội trong lúc session còn mở.
40
+ */
41
+ export const WARMUP_IDLE_REFRESH_MS = 240_000;
36
42
  export class Agent {
37
43
  /** v2 event schema: stable id for the run — links agent_start/agent_end. */
38
44
  id = randomUUID();
@@ -89,6 +95,25 @@ export class Agent {
89
95
  /** v2: build no-progress tracking — same failure signature twice → escalate. */
90
96
  lastVerifySignature = "";
91
97
  verifyStrikes = 0;
98
+ /**
99
+ * WARMUP (plan/WARMUP-PLAN.md): request prefill nền — dựng đúng prefix
100
+ * system+tools mà lượt thật sẽ gửi, max_tokens=1, vứt response. Null khi
101
+ * chưa trigger; `settled` sau khi LLM request kết thúc (thành công/abort/lỗi).
102
+ */
103
+ warmupRun = null;
104
+ /** True after dispose() — warm-up không được để MCP server con mọc lại. */
105
+ disposed = false;
106
+ /**
107
+ * FILE-READ-PERF P3 (plan/FILE-READ-PERF-PLAN.md): heartbeat re-warm khi idle —
108
+ * engine local có keep_alive ~5', model unload thì lượt kế trả 35–56s cold
109
+ * prefill (đo thật). Timer chạy CHỈ lúc nhàn rỗi (settleWarmup tắt, finish()
110
+ * bật lại), unref để không giữ process. CHỈ cho baseUrl loopback.
111
+ */
112
+ warmupIdleTimer = null;
113
+ /** True nếu frontend này THỰC sự dùng warm-up (REPL/TUI) — serve/run-once không heartbeat. */
114
+ warmupUsed = false;
115
+ /** Test knob: thu ngắn nhịp heartbeat (default WARMUP_IDLE_REFRESH_MS). */
116
+ warmupIdleRefreshMs = WARMUP_IDLE_REFRESH_MS;
92
117
  /** v2: ask_user plumbing (frontends register handlers; serve → not-interactive). */
93
118
  /** Guardrail Engine — khởi tạo trong constructor (cần this.config). */
94
119
  guardrails;
@@ -411,7 +436,7 @@ export class Agent {
411
436
  const servers = rc.mcpServers ?? {};
412
437
  this.config.mcpServers = servers;
413
438
  if (this.mcp) {
414
- void this.mcp.applyConfig(servers);
439
+ void this.mcp.applyConfig(servers).catch(() => { });
415
440
  }
416
441
  }
417
442
  });
@@ -424,6 +449,8 @@ export class Agent {
424
449
  abort() {
425
450
  this.aborted = true;
426
451
  this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
452
+ // WARMUP: SIGINT giết luôn request prefill nếu còn chạy nền.
453
+ this.warmupRun?.abort.abort(new DOMException("interrupted by user", "AbortError"));
427
454
  }
428
455
  /**
429
456
  * Steer: user input delivered at the NEXT safe step boundary (pattern from
@@ -848,8 +875,10 @@ export class Agent {
848
875
  * server tự cắt history, và mỗi lần nó cắt là một lần mất KV-cache.
849
876
  */
850
877
  async applyContextProbe() {
851
- if (this.config.contextWindow)
878
+ if (this.config.contextWindow) {
879
+ this.applyAdaptiveReadBudget();
852
880
  return;
881
+ }
853
882
  let probe = null;
854
883
  try {
855
884
  // Server local vẫn có thể bật --api-key: /props trả 401 nếu không gửi
@@ -875,11 +904,184 @@ export class Agent {
875
904
  if (probe.tokens !== catalog) {
876
905
  this.reporter.info(`context window: ${probe.tokens} token (${probe.detail}) — thay fallback ${catalog} token, compaction/trim tính theo số này`);
877
906
  }
907
+ this.applyAdaptiveReadBudget();
908
+ }
909
+ /**
910
+ * FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) P1: nới ngân sách một lần đọc
911
+ * theo window THẬT khi local + user không đặt tường minh. Chạy một lần; giá
912
+ * trị ghi cả vào config.limits (safety-net ở tool-executor) lẫn module budget
913
+ * mà read-file.ts cắt theo. Probe thất bại (không có window) → giữ floor cũ.
914
+ */
915
+ readBudgetAdapted = false;
916
+ applyAdaptiveReadBudget() {
917
+ if (this.readBudgetAdapted)
918
+ return;
919
+ const window = this.config.contextWindow;
920
+ if (!window || !isLocalBaseUrl(this.config.baseUrl) || this.config.localProfile === false)
921
+ return;
922
+ if (this.config.limits?.toolOutputChars !== undefined)
923
+ return; // user thắng, không đụng
924
+ const budget = adaptiveToolOutputChars(window);
925
+ this.config.limits = { ...this.config.limits, toolOutputChars: budget };
926
+ setMaxToolOutputChars(budget);
927
+ this.readBudgetAdapted = true;
928
+ this.logger.log({ type: "read_budget", chars: budget, window });
878
929
  }
879
930
  /** Rebuild built-in and connected MCP tool specs for the current skill scope. */
880
931
  rebuildToolList() {
881
932
  rebuildMcpToolList(this.mcpBridgeRuntime());
882
933
  }
934
+ /**
935
+ * WARMUP (plan/WARMUP-PLAN.md): prefill prefix system+tools NỀN ngay khi mở
936
+ * session, để lượt chat đầu không phải trả cold-load + 20–60k token prefill
937
+ * trên đường tới token đầu (đo: 30–120s trên máy cá nhân, xem P2-3). One-shot,
938
+ * idempotent, fire-and-forget — lỗi im lặng (best-effort).
939
+ * `rewarm` (FILE-READ-PERF P3): bản heartbeat — bỏ guard "chưa có messages",
940
+ * tiền đề là entry trước đã settle.
941
+ */
942
+ warmup(opts = {}) {
943
+ const w = this.warmupRun;
944
+ if (w && (!w.settled || !opts.rewarm))
945
+ return w.promise;
946
+ if (this.config.warmup === false)
947
+ return Promise.resolve();
948
+ // aborted chỉ chặn warm KHỞI ĐẦU; heartbeat sống lại sau lượt kế (resetRunSignal).
949
+ if (this.disposed || (this.aborted && !opts.rewarm))
950
+ return Promise.resolve();
951
+ if (!opts.rewarm && this.messages.length > 0)
952
+ return Promise.resolve();
953
+ if (!this.config.apiKey)
954
+ return Promise.resolve();
955
+ // I-12: harness eval thay client bằng scripted transcript — warm-up sẽ ăn
956
+ // mất một lượt kịch bản, làm hỏng mọi assertion đếm request.
957
+ if (this.config.clientFactory)
958
+ return Promise.resolve();
959
+ const abort = new AbortController();
960
+ const entry = { abort, settled: false, promise: Promise.resolve() };
961
+ this.warmupUsed = true;
962
+ entry.promise = this.runWarmup(abort.signal, () => {
963
+ entry.settled = true;
964
+ }, opts.rewarm === true);
965
+ this.warmupRun = entry;
966
+ return entry.promise;
967
+ }
968
+ async runWarmup(signal, settle, rewarm = false) {
969
+ const t0 = Date.now();
970
+ try {
971
+ // Dời hai việc network ra khỏi đường tới token đầu. run() vẫn gọi lại
972
+ // nguyên như cũ — cả hai đều return sớm khi đã hoàn tất ở đây.
973
+ await Promise.allSettled([this.applyContextProbe(), this.connectMcp()]);
974
+ if (signal.aborted || this.disposed)
975
+ return;
976
+ // FILE-READ-PERF P3: heartbeat sau khi đã chạy ≥1 lượt thật gửi lại
977
+ // HISTORY THẬT — đó mới là prefix của lượt kế tiếp (placeholder sẽ phân
978
+ // nhánh ngay sau system). Trước lượt đầu: dựng đúng messages như run().
979
+ const hasHistory = this.messages.length > 0;
980
+ // Tool list + messages dựng ĐÚNG như run() — cache-key (Anthropic
981
+ // breakpoint, prefix KV của llama.cpp) là từng byte JSON của schema.
982
+ const pendingAllowedTools = this.promptContext.pendingTools();
983
+ const allow = pendingAllowedTools?.length
984
+ ? new Set(pendingAllowedTools.map((t) => t.toLowerCase()))
985
+ : null;
986
+ let baseTools = applyToolFilter(allToolSpecs(), this.primaryDef, this.reporter);
987
+ if (allow)
988
+ baseTools = baseTools.filter((s) => allow.has(s.function.name.toLowerCase()));
989
+ const mcpSpecs = governedMcpSpecs(this.mcpBridgeRuntime());
990
+ const tools = allow
991
+ ? [...baseTools, ...mcpSpecs.filter((s) => allow.has(s.function.name.toLowerCase()))]
992
+ : [...baseTools, ...mcpSpecs];
993
+ const messages = hasHistory
994
+ ? this.messages
995
+ : buildRunMessages({
996
+ config: this.config,
997
+ primaryDef: this.primaryDef,
998
+ skills: this.promptContext.getSkills(),
999
+ buildCmd: this.buildCmd,
1000
+ // Placeholder KHÔNG mention file nào: system prompt trùng byte với
1001
+ // request thật ở mọi task không có focus-file guide (xem plan §7).
1002
+ task: "[warmup]",
1003
+ pendingContext: this.promptContext.peekPendingContext(),
1004
+ });
1005
+ const res = await this.client.chat(messages, tools, { signal, maxTokens: 1 });
1006
+ this.logger.log({
1007
+ type: "warmup",
1008
+ ok: true,
1009
+ rewarm,
1010
+ ms: Date.now() - t0,
1011
+ model: this.client.model,
1012
+ promptTokens: res.usage?.prompt_tokens,
1013
+ cachedTokens: res.usage?.cached_tokens,
1014
+ });
1015
+ }
1016
+ catch (err) {
1017
+ // Warm-up không bao giờ được làm hỏng run thật — kể cả SIGINT abort.
1018
+ this.logger.log({
1019
+ type: "warmup",
1020
+ ok: false,
1021
+ ms: Date.now() - t0,
1022
+ error: (err?.message ?? String(err)).slice(0, 200),
1023
+ });
1024
+ }
1025
+ finally {
1026
+ settle();
1027
+ // dispose() chạy giữa chừng: kill lại MCP servers mà connectMcp của
1028
+ // warm-up vừa spawn (killAll của dispose đã chạy TRƯỚC khi chúng mọc).
1029
+ if (this.disposed)
1030
+ this.shutdownMcp();
1031
+ // FILE-READ-PERF P3: cycle warm kết thúc TỰ NHIÊN (không bị supersede/
1032
+ // abort) → arm nhịp heartbeat tiếp theo. Bị abort nghĩa là run thật đang
1033
+ // chạy — finish() sẽ arm lại khi xong.
1034
+ else if (!signal.aborted)
1035
+ this.scheduleWarmupRefresh();
1036
+ }
1037
+ }
1038
+ /**
1039
+ * Đầu mọi run thật (run/runTurn/resume): lượt thật thắng warm-up — abort
1040
+ * request prefill nếu còn treo, rồi await cho settle để connectMcp/probe
1041
+ * không double-spawn server con song song với chính nó.
1042
+ */
1043
+ async settleWarmup() {
1044
+ // Run thật bắt đầu → heartbeat phải im lặng (timer re-arm ở finish()).
1045
+ this.clearWarmupTimer();
1046
+ const w = this.warmupRun;
1047
+ if (!w)
1048
+ return;
1049
+ if (!w.settled)
1050
+ w.abort.abort(new DOMException("real run supersedes warmup", "AbortError"));
1051
+ await w.promise.catch(() => { });
1052
+ }
1053
+ /** Tắt heartbeat idle (run thật vào / dispose). */
1054
+ clearWarmupTimer() {
1055
+ if (this.warmupIdleTimer) {
1056
+ clearTimeout(this.warmupIdleTimer);
1057
+ this.warmupIdleTimer = null;
1058
+ }
1059
+ }
1060
+ /**
1061
+ * FILE-READ-PERF P3: arm lại nhịp re-warm — chỉ khi engine local và bộ máy
1062
+ * warmup còn bật (đúng các guard của warmup()). unref(): timer không được
1063
+ * giữ process sống (run-once/test thoát sạch).
1064
+ */
1065
+ scheduleWarmupRefresh() {
1066
+ if (this.disposed || !this.warmupUsed)
1067
+ return;
1068
+ if (this.config.warmup === false || !this.config.apiKey || this.config.clientFactory) {
1069
+ this.clearWarmupTimer();
1070
+ return;
1071
+ }
1072
+ // KHÔNG phải local (kể cả /model đổi từ local → cloud giữa session): tắt
1073
+ // heartbeat đang arm — không đốt tiền API để giữ cache.
1074
+ if (!isLocalBaseUrl(this.config.baseUrl)) {
1075
+ this.clearWarmupTimer();
1076
+ return;
1077
+ }
1078
+ this.clearWarmupTimer();
1079
+ this.warmupIdleTimer = setTimeout(() => {
1080
+ this.warmupIdleTimer = null;
1081
+ void this.warmup({ rewarm: true }).catch(() => { });
1082
+ }, this.warmupIdleRefreshMs);
1083
+ this.warmupIdleTimer.unref();
1084
+ }
883
1085
  /**
884
1086
  * Disconnect MCP servers when the agent is disposed — connections are kept
885
1087
  * alive for the whole agent/session lifetime (one "connected" line, no
@@ -891,8 +1093,13 @@ export class Agent {
891
1093
  }
892
1094
  /** M4: đóng hot-reload watcher (khi agent bị hủy vĩnh viễn). */
893
1095
  dispose() {
1096
+ this.disposed = true;
1097
+ this.clearWarmupTimer();
894
1098
  this.watcher?.close();
895
1099
  this.watcher = null;
1100
+ // WARMUP: hủy request prefill đang chạy (chặn MCP servers connect thêm
1101
+ // sau khi kill — runWarmup tự shutdownMcp lần cuối trong finally).
1102
+ this.warmupRun?.abort.abort(new DOMException("agent disposed", "AbortError"));
896
1103
  this.shutdownMcp();
897
1104
  }
898
1105
  /**
@@ -934,6 +1141,8 @@ export class Agent {
934
1141
  if (!this.config.apiKey) {
935
1142
  throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
936
1143
  }
1144
+ // WARMUP: lượt thật thắng request prefill đang chạy nền (xem warmup()).
1145
+ await this.settleWarmup();
937
1146
  const buildCmd = this.buildCmd;
938
1147
  const rawTask = task;
939
1148
  // @file mention expansion — task trước khi build messages.
@@ -974,6 +1183,8 @@ export class Agent {
974
1183
  else {
975
1184
  this.tools = [...baseTools, ...mcpSpecs];
976
1185
  }
1186
+ // Tool list (re)built for this run — previous calibration no longer valid.
1187
+ this.measuredFixedTokens = null;
977
1188
  this.messages = buildRunMessages({
978
1189
  config: this.config,
979
1190
  primaryDef: this.primaryDef,
@@ -1025,6 +1236,9 @@ export class Agent {
1025
1236
  if (this.messages.length === 0) {
1026
1237
  return this.run(task);
1027
1238
  }
1239
+ // WARMUP: thường đã settle ở run() đầu; await lại cho chắc (race /clear+
1240
+ // warm-up + runTurn ngay lập tức).
1241
+ await this.settleWarmup();
1028
1242
  const buildCmd = this.buildCmd;
1029
1243
  const rawTask = task;
1030
1244
  task = expandMentions(task, this.config.workdir);
@@ -1033,6 +1247,10 @@ export class Agent {
1033
1247
  this.resetUsageTotals();
1034
1248
  this.filesModified = false;
1035
1249
  this.plan = [];
1250
+ this.runDiffs = [];
1251
+ this.selfReviewDone = false;
1252
+ this.verifyStrikes = 0;
1253
+ this.lastVerifySignature = "";
1036
1254
  for (const content of this.promptContext.takePendingContext())
1037
1255
  this.messages.push({ role: "user", content });
1038
1256
  this.messages.push({ role: "user", content: task });
@@ -1049,6 +1267,9 @@ export class Agent {
1049
1267
  * An optional follow-up task is appended as a new user message.
1050
1268
  */
1051
1269
  async resume(session, followUp) {
1270
+ // WARMUP: session restore mang system prompt RIÊNG (từ session cũ) — hủy
1271
+ // request prefill của prompt mới để không nhân đôi prefill nền.
1272
+ await this.settleWarmup();
1052
1273
  const buildCmd = this.buildCmd;
1053
1274
  this.sessionCreatedAt = session.createdAt;
1054
1275
  this.currentTask = session.task;
@@ -1325,6 +1546,8 @@ export class Agent {
1325
1546
  : { ok: null, detail: "no verification command ran" };
1326
1547
  this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000), usage: this.getUsage(), verification });
1327
1548
  this.logger.log({ type: "finish", success, steps });
1549
+ // FILE-READ-PERF P3: run kết thúc → session quay về nhàn rỗi, arm heartbeat.
1550
+ this.scheduleWarmupRefresh();
1328
1551
  return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
1329
1552
  }
1330
1553
  }
package/dist/cli/repl.js CHANGED
@@ -12,6 +12,9 @@ export async function repl(cfg) {
12
12
  // every input is a follow-up turn, context carries over, saved continuously.
13
13
  // /clear swaps in a fresh Agent (the /clear branch reassigns this).
14
14
  let agent = new Agent(cfg);
15
+ // WARMUP (plan/WARMUP-PLAN.md): dựng MCP+probe+prefill system+tools nền
16
+ // trong lúc user còn đọc prompt — lệnh đầu không trả 30–120s cold prefill.
17
+ void agent.warmup();
15
18
  process.on("SIGINT", () => {
16
19
  ui.warn("\nSIGINT — aborting current task...");
17
20
  agent.abort();
@@ -37,6 +40,9 @@ export async function repl(cfg) {
37
40
  agent.dispose();
38
41
  agent = next ?? new Agent(cfg);
39
42
  first = true;
43
+ // Agent mới = prefix mới (session id, repo state) — warm-up lại từ đầu.
44
+ // Với `next` đã có messages (resume) warmup() tự no-op.
45
+ void agent.warmup();
40
46
  },
41
47
  };
42
48
  const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
@@ -68,11 +68,29 @@ export const LOCAL_PROFILE_DEFAULTS = {
68
68
  maxSteps: 24,
69
69
  /** P2-3: prefill 20–60k token trên máy cá nhân có thể vượt 120s → timeout + retry. */
70
70
  requestTimeoutMs: 600_000,
71
- /** P0-2: history của MỘT tool result nhỏ hơn mặc định cloud (8k). */
72
- toolOutputChars: 6_000,
71
+ /**
72
+ * FILE-READ-PERF P1: profile KHÔNG pin toolOutputChars nữa (trước là 6000) —
73
+ * ngân sách đọc giờ suy từ context window THẬT qua adaptiveToolOutputChars,
74
+ * apply ở cuối applyContextProbe (agent.ts). Window nhỏ vẫn ra 6000 như cũ.
75
+ */
73
76
  /** P0-3: trần token cho MCP schema; 46 tool GitHub MCP ≈ 12k token mỗi lượt. */
74
77
  mcpSchemaTokens: 6_000,
75
78
  };
79
+ /**
80
+ * FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) N1: một lần đọc file bị cắt
81
+ * thành 5–6 MẢNH vì budget 6000 ký tự, mà mỗi mảnh = một lượt LLM tròn
82
+ * (đo: 8–11s/mảnh với README 28KB). Nới budget theo window đã probe:
83
+ * 0.17 ký tự mỗi token window ≈ 4% history cho một tool result
84
+ * (4 chars ≈ 1 token) — window 16k giữ nguyên 6000, 96k lên trần 16000.
85
+ */
86
+ export const TOOL_OUTPUT_WINDOW_SHARE = 0.17;
87
+ export const TOOL_OUTPUT_CHARS_FLOOR = 6_000;
88
+ export const TOOL_OUTPUT_CHARS_CEIL = 16_000;
89
+ export function adaptiveToolOutputChars(windowTokens) {
90
+ if (!Number.isFinite(windowTokens) || windowTokens <= 0)
91
+ return TOOL_OUTPUT_CHARS_FLOOR;
92
+ return Math.min(TOOL_OUTPUT_CHARS_CEIL, Math.max(TOOL_OUTPUT_CHARS_FLOOR, Math.round(windowTokens * TOOL_OUTPUT_WINDOW_SHARE)));
93
+ }
76
94
  /**
77
95
  * Áp mặc định phù hợp cho local engine. Trả về danh sách thay đổi đã áp (để
78
96
  * log/kiểm thử); rỗng khi không áp dụng (cloud, hoặc localProfile:false).
@@ -98,10 +116,8 @@ export function applyLocalProfile(cfg, explicit = {}) {
98
116
  limits.requestTimeoutMs = LOCAL_PROFILE_DEFAULTS.requestTimeoutMs;
99
117
  applied.push(`requestTimeoutMs=${LOCAL_PROFILE_DEFAULTS.requestTimeoutMs}`);
100
118
  }
101
- if (limits.toolOutputChars === undefined) {
102
- limits.toolOutputChars = LOCAL_PROFILE_DEFAULTS.toolOutputChars;
103
- applied.push(`toolOutputChars=${LOCAL_PROFILE_DEFAULTS.toolOutputChars}`);
104
- }
119
+ // FILE-READ-PERF P1: toolOutputChars KHÔNG pin ở đây nữa — để undefined để
120
+ // applyAdaptiveReadBudget nhận diện "user không đặt" và suy từ window.
105
121
  if (limits.mcpSchemaTokens === undefined) {
106
122
  limits.mcpSchemaTokens = LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens;
107
123
  applied.push(`mcpSchemaTokens=${LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens}`);
@@ -304,6 +320,7 @@ export function loadConfig(overrides = {}) {
304
320
  agent: overrides.agent ?? process.env.SCODER_AGENT ?? rc.agent,
305
321
  routing: rc.routing,
306
322
  repoMap: rc.repoMap,
323
+ warmup: rc.warmup,
307
324
  promptVersion: rc.promptVersion,
308
325
  verifyCmds: rc.verifyCmds,
309
326
  selfReview: rc.selfReview,
@@ -259,6 +259,10 @@ export const PROMPT_SECTIONS = {
259
259
  tools: `## Tool policy
260
260
  - Orient with list_files / grep / glob / repo_map — prefer them over shell
261
261
  greps. read_file before every edit_file.
262
+ - read_file: when a result ends with "N more line(s)", read the REST in ONE
263
+ step — issue several read_file calls with successive offset/limit covering
264
+ every remaining chunk at once (they run in parallel). Never re-read lines
265
+ you already have.
262
266
  - edit_file: old_string MUST appear exactly once — otherwise re-read the file
263
267
  and add more context.
264
268
  - run_command IS the build command: run it only when you changed files; a
@@ -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/llm/llm.js CHANGED
@@ -169,7 +169,9 @@ export class LlmClient {
169
169
  messages: toWireMessages(messages),
170
170
  tools: tools.length > 0 ? tools : undefined,
171
171
  temperature: this.opts.temperature,
172
- max_tokens: this.opts.maxTokens,
172
+ // WARMUP: override max_tokens cho request prefill (1 token là đủ để
173
+ // engine tính xong KV-cache của prefix) — lượt thật không truyền key này.
174
+ max_tokens: opts.maxTokens ?? this.opts.maxTokens,
173
175
  stream: false,
174
176
  });
175
177
  let res;
@@ -153,12 +153,12 @@ export class AnthropicClient {
153
153
  this.opts = { ...this.opts, model };
154
154
  }
155
155
  /** I-05: request body builder — shared by chat/streamChat. */
156
- buildBody(system, messages, tools, stream) {
156
+ buildBody(system, messages, tools, stream, maxTokensOverride) {
157
157
  // Extended thinking: temperature must be 1 và thinking block format.
158
158
  const thinkingOn = (this.opts.thinkingBudgetTokens ?? 0) > 0;
159
159
  return JSON.stringify({
160
160
  model: this.opts.model,
161
- max_tokens: this.opts.maxTokens,
161
+ max_tokens: maxTokensOverride ?? this.opts.maxTokens,
162
162
  temperature: thinkingOn ? 1 : this.opts.temperature,
163
163
  ...(thinkingOn ? { thinking: { type: "enabled", budget_tokens: this.opts.thinkingBudgetTokens } } : {}),
164
164
  system,
@@ -172,7 +172,9 @@ export class AnthropicClient {
172
172
  const { system, messages: translated } = toAnthropicMessages(messages);
173
173
  const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
174
174
  const cached = this.applyCacheControl(system, translated, tools);
175
- const body = this.buildBody(cached.system, cached.messages, cached.tools, false);
175
+ // WARMUP: override max_tokens cho request prefill — các breakpoint
176
+ // cache_control (tools + system) đã áp dụng y hệt lượt thật.
177
+ const body = this.buildBody(cached.system, cached.messages, cached.tools, false, opts.maxTokens);
176
178
  let res;
177
179
  try {
178
180
  res = await withRetry(async () => {
package/dist/llm/retry.js CHANGED
@@ -32,6 +32,10 @@ export async function withRetry(fn, opts = {}, onRetry) {
32
32
  catch (err) {
33
33
  lastErr = err;
34
34
  const status = err.status;
35
+ // WARMUP: abort của CHÍNH caller (warm-up bị lượt thật supersede, SIGINT)
36
+ // không bao giờ được retry — message có thể là bất kỳ chuỗi nào.
37
+ if (err?.name === "AbortError")
38
+ throw err;
35
39
  const retryable = (typeof status === "number" && isRetryableStatus(status)) ||
36
40
  (!(err instanceof NonRetryableError) && isRetryableNetworkError(err));
37
41
  // Client errors (except 429) — fail immediately
@@ -89,8 +89,8 @@ export class McpManager {
89
89
  .listTools()
90
90
  .then((defs) => this.reregisterTools(name, conn, defs))
91
91
  .catch(() => { });
92
- void this.refreshResources(name, conn);
93
- void this.refreshPrompts(name, conn);
92
+ void this.refreshResources(name, conn).catch(() => { });
93
+ void this.refreshPrompts(name, conn).catch(() => { });
94
94
  for (const uri of this.subscriptions.get(name) ?? []) {
95
95
  void conn.subscribeResource(uri).catch(() => { });
96
96
  }
@@ -272,22 +272,29 @@ export class PermissionChecker {
272
272
  // T3: timeout enforced HERE (checker-level) — a hung frontend handler
273
273
  // (TUI modal never answered, server client disconnected) must not leak
274
274
  // the pending permission promise forever.
275
- const answer = await Promise.race([
276
- this.promptHandler(req),
277
- new Promise((_, rej) => setTimeout(() => {
275
+ let timer;
276
+ const timeoutPromise = new Promise((_, rej) => {
277
+ timer = setTimeout(() => {
278
278
  const e = new Error("permission handler timed out");
279
279
  e.name = "AbortError";
280
280
  rej(e);
281
- }, req.timeoutSecs * 1000)),
282
- ]);
283
- if (answer === "quit")
284
- this.abortRequested = true;
285
- if (answer === "always" && req.kind === "edit")
286
- this.allowAllEdits = true;
287
- if (answer === "always" && req.kind === "shell" && req.alwaysAllowPrefix) {
288
- this.sessionAllow.addPrefix(req.alwaysAllowPrefix);
281
+ }, req.timeoutSecs * 1000);
282
+ });
283
+ try {
284
+ const answer = await Promise.race([this.promptHandler(req), timeoutPromise]);
285
+ if (answer === "quit")
286
+ this.abortRequested = true;
287
+ if (answer === "always" && req.kind === "edit")
288
+ this.allowAllEdits = true;
289
+ if (answer === "always" && req.kind === "shell" && req.alwaysAllowPrefix) {
290
+ this.sessionAllow.addPrefix(req.alwaysAllowPrefix);
291
+ }
292
+ return answer === "yes" || answer === "always";
293
+ }
294
+ finally {
295
+ if (timer)
296
+ clearTimeout(timer);
289
297
  }
290
- return answer === "yes" || answer === "always";
291
298
  }
292
299
  catch {
293
300
  return false; // handler error/timeout — deny
@@ -461,6 +468,19 @@ export class PermissionChecker {
461
468
  };
462
469
  }
463
470
  }
471
+ // Command substitution $(...) / backticks can hide arbitrary commands inside
472
+ // an otherwise-allowed prefix (e.g. `echo $(rm -rf /)`). Treat as ask
473
+ // unless an explicit rule allows the whole command line.
474
+ if (scan?.ok && scan.hasCommandSubstitution) {
475
+ const e = matchRule(this.activeRules(), "shell", command);
476
+ if (e !== "allow") {
477
+ return {
478
+ decision: "ask",
479
+ reason: "command substitution ($() / backticks) — approval required",
480
+ alwaysAllowPrefix: extractFirstWord(command) + " ",
481
+ };
482
+ }
483
+ }
464
484
  return this.classify(command);
465
485
  }
466
486
  /**
@@ -74,8 +74,10 @@ export function toolReadFile(workdir, args) {
74
74
  const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
75
75
  const footer = dropped > 0
76
76
  ? `\n... [${dropped} more line(s): ${end + 1}-${offset + slice.length - 1} of ${total}. ` +
77
- `Read the next chunk with read_file({"path":"${args.path}","offset":${end + 1}}) — ` +
78
- `do NOT re-read from the start.]`
77
+ `Read the REST in ONE step — parallel read_file calls at ` +
78
+ `{"path":"${args.path}","offset":${end + 1}}, ` +
79
+ `{"path":"${args.path}","offset":${end + 1 + kept.length}}, ... ` +
80
+ `until the file is done; do NOT re-read from the start.]`
79
81
  : "";
80
82
  // clamp cho dòng kếch xù (một dòng 40k ký tự vẫn phải nằm trong ngân sách).
81
83
  const numbered = clamp(kept.join("\n"), budget);
@@ -116,23 +116,34 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
116
116
  cwd: workdir,
117
117
  stdio: ["ignore", "pipe", "pipe"],
118
118
  env: buildChildEnv(workdir), // G3: allowlist — không kế thừa full process.env
119
+ // Own process group so killTree(-pid) can reap grandchildren (see killTree).
120
+ detached: process.platform !== "win32",
119
121
  });
122
+ let exited = false;
120
123
  let stdout = "";
121
124
  let stderr = "";
122
125
  let killedReason = null;
123
- const killChild = (reason) => {
124
- killedReason = reason;
126
+ // Linux/macOS: sh fork command con (sleep, make, npm…) rồi chết trước —
127
+ // kill mỗi `child` để chúng mồ côi, còn giữ PIPE => 'close' không bao giờ
128
+ // tới, tool treo, run không finish (fail CI serve-abort reproducibly).
129
+ // detached:true cho child một process group riêng -> kill cả nhóm qua -pid.
130
+ const killTree = (sig) => {
125
131
  try {
126
- child.kill("SIGTERM");
132
+ if (process.platform !== "win32" && child.pid)
133
+ process.kill(-child.pid, sig);
134
+ else
135
+ child.kill(sig);
127
136
  }
128
- catch { /* already dead */ }
129
- // SIGKILL fallback if the process ignores SIGTERM
137
+ catch { /* already dead / group gone */ }
138
+ };
139
+ const killChild = (reason) => {
140
+ killedReason = reason;
141
+ killTree("SIGTERM");
142
+ // SIGKILL escalation — sh chết nhưng GRANDCHILD ignore TERM thì group
143
+ // vẫn sống; exitCode của bản thân sh không phản ánh điều đó, dùng `exited`.
130
144
  setTimeout(() => {
131
- try {
132
- if (child.exitCode === null)
133
- child.kill("SIGKILL");
134
- }
135
- catch { /* already dead */ }
145
+ if (!exited)
146
+ killTree("SIGKILL");
136
147
  }, 5_000).unref();
137
148
  };
138
149
  const timer = setTimeout(() => killChild("timeout"), timeoutMs);
@@ -156,34 +167,41 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
156
167
  child.on("error", (err) => {
157
168
  finish({ ok: false, output: clamp(`${echoLine}\n(spawn error) ${err.message}`) });
158
169
  });
159
- child.on("close", (code) => {
170
+ const buildResult = (code) => {
160
171
  if (killedReason === "timeout") {
161
- finish({
172
+ return {
162
173
  ok: false,
163
174
  output: clamp(`${echoLine}\n(command timed out after ${Math.round(timeoutMs / 1000)}s and was killed)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
164
- });
165
- return;
175
+ };
166
176
  }
167
177
  if (killedReason === "aborted") {
168
- finish({
178
+ return {
169
179
  ok: false,
170
180
  output: clamp(`${echoLine}\n(interrupted by user)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
171
- });
172
- return;
181
+ };
173
182
  }
174
183
  const status = code ?? -1;
175
184
  // G3: redact trước khi output vào conversation/log/TUI.
176
185
  const redacted = redactSecrets(stdout);
177
186
  const redactedErr = redactSecrets(stderr);
178
187
  if (status === 0) {
179
- finish({ ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) });
180
- }
181
- else {
182
- finish({
183
- ok: false,
184
- output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
185
- });
188
+ return { ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) };
186
189
  }
190
+ return {
191
+ ok: false,
192
+ output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
193
+ };
194
+ };
195
+ // 'close' chờ EOF của pipe; grandchild lì (khác process group — hiếm, hoặc
196
+ // spawn của chính command) có thể giữ nó mở vĩnh viễn. Đã kill mà child
197
+ // exit rồi thì resolve ngay trên 'exit', đừng đợi close.
198
+ child.on("exit", (code) => {
199
+ exited = true;
200
+ if (killedReason)
201
+ finish(buildResult(code));
202
+ });
203
+ child.on("close", (code) => {
204
+ finish(buildResult(code));
187
205
  });
188
206
  });
189
207
  }
@@ -91,7 +91,14 @@ export function toolGrep(workdir, args) {
91
91
  // status >= 2 → rg error; fall through to grep fallback.
92
92
  }
93
93
  // Fallback: grep -rn (portable) — output already path:line:text.
94
+ // CI runners (ubuntu-latest) không có rg → đường này là mainstream, phải
95
+ // hỗ trợ include + skip noise dirs như rg; grep BSD không hiểu các cờ này
96
+ // sẽ exit ≥2 → rơi tiếp xuống jsGrepFallback (xử lý đúng, không mất sạch).
94
97
  const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
98
+ for (const d of SEARCH_SKIP)
99
+ gArgs.push(`--exclude-dir=${d}`);
100
+ if (args.include)
101
+ gArgs.push("--include", args.include);
95
102
  if (ctxBefore > 0)
96
103
  gArgs.push("-B", String(ctxBefore));
97
104
  if (ctxAfter > 0)
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
@@ -72,6 +73,8 @@ export function TuiApp({ config, initialTask }) {
72
73
  const [input, setInput] = useState(initialTask ?? "");
73
74
  /** Rows scrolled up from the bottom; 0 = follow the newest content. */
74
75
  const [scrollOffset, setScrollOffset] = useState(0);
76
+ /** Ctrl+O: native terminal text selection (mouse tracking released). */
77
+ const [selectMode, setSelectMode] = useState(false);
75
78
  const [permMode, setPermMode] = useState(cfg.permissionMode);
76
79
  // T-03: active permission prompt (modal) — the run blocks until answered.
77
80
  const [permPrompt, setPermPrompt] = useState(null);
@@ -95,13 +98,25 @@ export function TuiApp({ config, initialTask }) {
95
98
  agentRef.current?.dispose();
96
99
  agentRef.current = null;
97
100
  }, []);
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);
101
+ // ── Scrollable chat viewport (Ink 8 alt-screen) — P1-8 virtualized ──
102
+ // Chi phí layout/wrap mỗi frame tỷ lệ với số item MOUNT, không phải số
103
+ // item transcript ⇒ chỉ mount cửa sổ quanh viewport (transcriptWindow).
104
+ // Scroll offset khi đọc lại dùng rows ước lượng; khi BÁM ĐÁY dùng chiều
105
+ // cao THẬT của cửa sổ đã mount (đo rẻ vì cửa sổ ~viewport+overscan) —
106
+ // estimate sai lệch bao nhiêu thì dòng cuối vẫn luôn thấy.
107
+ const { viewportRef, viewport, contentRef, content, viewportRowsRef } = useScrollMetrics(setScrollOffset, !selectMode);
108
+ const viewportRows = viewport.clientHeight || 24;
109
+ const win = useMemo(() => {
110
+ const total = estimateTotalRows(state.items);
111
+ const max = Math.max(0, total - viewportRows);
112
+ const top = Math.max(0, max - scrollOffset);
113
+ return { ...transcriptWindow(state.items, top, viewportRows), maxScroll: max };
114
+ }, [state.items, scrollOffset, viewportRows]);
115
+ const maxScroll = win.maxScroll;
116
+ // Follow-bottom (scrollOffset = 0): offset chính xác theo số đo thật.
117
+ const contentOffset = scrollOffset === 0 && content.hasMeasured
118
+ ? Math.max(0, content.height - viewport.clientHeight)
119
+ : win.offset;
105
120
  const skills = useMemo(() => discoverSkills(cfg.workdir), [cfg.workdir]);
106
121
  const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
107
122
  // T-02: completion sources (agents + files) computed once per mount.
@@ -141,8 +156,19 @@ export function TuiApp({ config, initialTask }) {
141
156
  useTuiKeybinds({
142
157
  scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen,
143
158
  setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef,
144
- setScrollOffset, exit,
159
+ setScrollOffset, selectMode, setSelectMode, exit,
145
160
  });
161
+ // WARMUP (plan/WARMUP-PLAN.md): tạo agent + prefill prefix system+tools nền
162
+ // NGAY khi mở TUI — TTFT của câu chat đầu không còn phải trả cold-load model
163
+ // và 20–60k token prefill. Bỏ qua khi có initialTask (submit trả prefill ngay
164
+ // sau mount, warm-up chỉ nhân đôi việc — settleWarmup sẽ abort nó).
165
+ const warmupRef = useRef(false);
166
+ useEffect(() => {
167
+ if (initialTask || warmupRef.current)
168
+ return;
169
+ warmupRef.current = true;
170
+ void createAgent().warmup();
171
+ }, [initialTask]);
146
172
  // Submit an initial task passed on the command line once, after mount.
147
173
  const bootRef = useRef(false);
148
174
  useEffect(() => {
@@ -164,19 +190,20 @@ export function TuiApp({ config, initialTask }) {
164
190
  setScrollOffset(0);
165
191
  prevStatusRef.current = state.status;
166
192
  }, [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.
193
+ // P1-8: viewport mount CỬA SỔ item quanh scrollTop (transcriptWindow), không
194
+ // mount cả transcript — chi phí layout/wrap mỗi frame hết tỷ lệ với tổng text.
195
+ // Ink clip bằng overflow="hidden"; contentOffsetY = số dòng phải ẩn ở đầu
196
+ // cửa sổ (scrollTop - startRow[item_start]).
170
197
  const { rows, columns } = useWindowSize();
171
198
  const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
172
199
  return (
173
200
  // flexShrink={0}: pinned chrome must never be squeezed when the chat
174
201
  // content overflows — Ink defaults every box to flexShrink:1, which
175
202
  // 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 ? (
203
+ _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
204
  // Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
178
205
  // 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 })] }));
206
+ _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" })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "ctrl+o" }), _jsx(Text, { dimColor: true, children: " select/copy (t\u1EAFt mouse tracking \u0111\u1EC3 t\u00F4 \u0111en k\u00E9o chu\u1ED9t)" })] })] })) : (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, selectMode: selectMode })] }));
180
207
  }
181
208
  /** Mount the TUI (called from the `scoder tui` subcommand). */
182
209
  export async function runTui(config, initialTask) {
@@ -67,9 +67,10 @@ function PhaseBreadcrumb({ phases }) {
67
67
  /** Metrics cho narrow-terminal — không còn dùng (sidebar đã thay thế), giữ để tham khảo. */
68
68
  /**
69
69
  * Status bar — bottom row. Left: spinner · status · elapsed · model.
70
- * Right: scroll position OR keyboard hints.
70
+ * Right: scroll position OR keyboard hints. In select mode (Ctrl+O) the hints
71
+ * swap to the copy reminder — the mouse belongs to the terminal there.
71
72
  */
72
- export function StatusBar({ state, scroll }) {
73
+ export function StatusBar({ state, scroll, selectMode = false }) {
73
74
  const { columns } = useWindowSize();
74
75
  const width = Math.max(40, (columns ?? 100) - 6);
75
76
  const statusColor = state.status === "running" ? theme.accent :
@@ -86,10 +87,12 @@ export function StatusBar({ state, scroll }) {
86
87
  state.status === "aborted" ? "⚠" :
87
88
  state.status === "idle" ? "○" : null;
88
89
  const model = state.model || "—";
89
- // Right side: scroll position OR helpful keybinds
90
- const rightText = scroll
91
- ? `scroll ${scroll} · esc→tail`
92
- : `⏎ send · ctrl+s sessions · ctrl+t ${state.thinkingVisible ? "◉" : "○"} · esc abort · ctrl+c quit`;
90
+ // Right side: select-mode reminder OR scroll position OR keybind hints
91
+ const rightText = selectMode
92
+ ? "⊝ select · drag to highlight · ⌘C/ctrl+c copy · ctrl+o back"
93
+ : scroll
94
+ ? `scroll ${scroll} · esc→tail`
95
+ : `⏎ send · ctrl+s sessions · ctrl+t ${state.thinkingVisible ? "◉" : "○"} · esc abort · ctrl+c quit`;
93
96
  const leftBudget = Math.max(24, width - rightText.length - 3);
94
97
  let left = statusLabel;
95
98
  if (model && model !== "—") {
@@ -3,13 +3,18 @@ import { useInput } from "ink";
3
3
  import { useEffect, useRef } from "react";
4
4
  import { markAborted } from "../state.js";
5
5
  export function useTuiKeybinds(deps) {
6
- const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, exit } = deps;
6
+ const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, selectMode, setSelectMode, exit } = deps;
7
7
  // Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
8
8
  // the first Esc returns to the bottom instead.
9
9
  const scrollOffsetRef = useRef(0);
10
10
  useEffect(() => {
11
11
  scrollOffsetRef.current = scrollOffset;
12
12
  }, [scrollOffset]);
13
+ // useInput closures go stale between renders (same reason as scrollOffsetRef).
14
+ const selectModeRef = useRef(false);
15
+ useEffect(() => {
16
+ selectModeRef.current = selectMode;
17
+ }, [selectMode]);
13
18
  // T-03: permission modal keybinds — intercepts y/a/n/q while open.
14
19
  useInput((input, key) => {
15
20
  const p = permPromptRef.current;
@@ -47,6 +52,24 @@ export function useTuiKeybinds(deps) {
47
52
  update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
48
53
  return;
49
54
  }
55
+ // Ctrl+O: toggle select mode — mouse tracking off (native drag-select +
56
+ // Cmd/Ctrl+C copy) / on (wheel scroll). Scroll with PgUp/PgDn while off.
57
+ if (key.ctrl && _input === "o") {
58
+ const next = !selectModeRef.current;
59
+ selectModeRef.current = next;
60
+ setSelectMode(next);
61
+ update((s) => ({
62
+ ...s,
63
+ items: [...s.items, {
64
+ kind: "notice",
65
+ text: next
66
+ ? "select mode ON — drag to highlight, copy with ⌘C/Ctrl+C · scroll via PgUp/PgDn · Ctrl+O back to mouse wheel"
67
+ : "select mode OFF — mouse wheel scrolling restored",
68
+ level: "info",
69
+ }],
70
+ }));
71
+ return;
72
+ }
50
73
  if (key.pageUp || key.pageDown || key.home || key.end) {
51
74
  const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
52
75
  if (key.pageUp)
@@ -1,8 +1,13 @@
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.
5
+ * mouseEnabled=false (select mode, Ctrl+O): nhả mouse tracking để terminal
6
+ * cho tô đen kéo chuột + copy thường — wheel scroll tắt, dùng PgUp/PgDn. */
2
7
  import { useCallback, useEffect, useRef } from "react";
3
8
  import { useBoxMetrics, useStdin } from "ink";
4
9
  import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "../mouse.js";
5
- export function useScrollMetrics(setScrollOffset) {
10
+ export function useScrollMetrics(setScrollOffset, mouseEnabled = true) {
6
11
  const viewportRef = useRef(null);
7
12
  const contentRef = useRef(null);
8
13
  const viewport = useBoxMetrics(viewportRef);
@@ -14,6 +19,8 @@ export function useScrollMetrics(setScrollOffset) {
14
19
  // T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
15
20
  // drops unrepresentable CSI sequences, so wheel events must be read here.
16
21
  // Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
22
+ // Select mode (mouseEnabled=false): MOUSE_DISABLE → native terminal drag
23
+ // selection/copy returns; setRawMode calls stay balanced across toggles.
17
24
  const { stdin, setRawMode, isRawModeSupported } = useStdin();
18
25
  const wheelScroll = useCallback((dir) => {
19
26
  setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
@@ -21,6 +28,10 @@ export function useScrollMetrics(setScrollOffset) {
21
28
  useEffect(() => {
22
29
  if (process.stdout.isTTY !== true || !isRawModeSupported)
23
30
  return;
31
+ if (!mouseEnabled) {
32
+ process.stdout.write(MOUSE_DISABLE);
33
+ return;
34
+ }
24
35
  process.stdout.write(MOUSE_ENABLE);
25
36
  setRawMode(true);
26
37
  const onData = (chunk) => {
@@ -32,7 +43,8 @@ export function useScrollMetrics(setScrollOffset) {
32
43
  return () => {
33
44
  stdin.off("data", onData);
34
45
  process.stdout.write(MOUSE_DISABLE);
46
+ setRawMode(false);
35
47
  };
36
- }, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
37
- return { viewportRef, contentRef, viewport, content, viewportRowsRef };
48
+ }, [stdin, setRawMode, isRawModeSupported, wheelScroll, mouseEnabled]);
49
+ return { viewportRef, viewport, contentRef, content, viewportRowsRef };
38
50
  }
@@ -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.
@@ -8,7 +8,7 @@ import { readdirSync } from "node:fs";
8
8
  import { join, relative } from "node:path";
9
9
  import fuzzysort from "fuzzysort";
10
10
  const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
11
- let cache = null;
11
+ const cache = new Map();
12
12
  /**
13
13
  * File index for `workdir` (cached, TTL 5s; invalidateFileIndex on tool writes).
14
14
  * `force` (P1-2) bỏ qua cache — dùng cho fingerprint, nơi một file mới được tạo
@@ -16,8 +16,9 @@ let cache = null;
16
16
  * sẽ được tái sử dụng sai.
17
17
  */
18
18
  export function fileIndex(workdir, force = false) {
19
- if (!force && cache && Date.now() - cache.built < 5_000)
20
- return cache.files;
19
+ const hit = cache.get(workdir);
20
+ if (!force && hit && Date.now() - hit.built < 5_000)
21
+ return hit.files;
21
22
  const files = [];
22
23
  // Fast path: ripgrep --files (gitignore-aware). Bare-name globs match at any
23
24
  // depth; `!**/x/**` does NOT match absolute paths (I-07 lesson).
@@ -36,12 +37,12 @@ export function fileIndex(workdir, force = false) {
36
37
  if (files.length === 0)
37
38
  walkFiles(workdir, "", files, 0);
38
39
  files.sort();
39
- cache = { files, built: Date.now() };
40
+ cache.set(workdir, { files, built: Date.now() });
40
41
  return files;
41
42
  }
42
43
  /** Cache invalidate — call after write/edit tool_result (scope: whole index). */
43
44
  export function invalidateFileIndex() {
44
- cache = null;
45
+ cache.clear();
45
46
  }
46
47
  function walkFiles(root, prefix, out, depth) {
47
48
  if (depth > 6)
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.14",
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",