s-app-scoder-agent 0.9.2 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +615 -638
  2. package/dist/{guardrails.js → agent/guardrails.js} +1 -1
  3. package/dist/agent/interaction.js +70 -0
  4. package/dist/agent/mcp-bridge.js +136 -0
  5. package/dist/agent/prompt-context.js +83 -0
  6. package/dist/agent/run-loop.js +318 -0
  7. package/dist/agent/session-persistence.js +61 -0
  8. package/dist/agent/tool-executor.js +158 -0
  9. package/dist/agent/types.js +88 -0
  10. package/dist/agent/verification.js +99 -0
  11. package/dist/agent.js +239 -1002
  12. package/dist/cli/args.js +230 -0
  13. package/dist/cli/init.js +79 -0
  14. package/dist/cli/main.js +211 -0
  15. package/dist/cli/mcp-command.js +185 -0
  16. package/dist/cli/repl.js +77 -0
  17. package/dist/cli/run-once.js +26 -0
  18. package/dist/{tui/commands.js → commands/definitions.js} +6 -16
  19. package/dist/commands/registry.js +260 -0
  20. package/dist/{config.js → config/config.js} +1 -1
  21. package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
  22. package/dist/{prompt.js → context/prompt.js} +111 -9
  23. package/dist/{skills.js → context/skills.js} +41 -2
  24. package/dist/index.js +5 -798
  25. package/dist/io/console-reporter.js +24 -0
  26. package/dist/io/reporter.js +1 -0
  27. package/dist/mcp/connection.js +227 -0
  28. package/dist/mcp/env.js +8 -0
  29. package/dist/{mcp.js → mcp/manager.js} +3 -515
  30. package/dist/mcp/sse.js +28 -0
  31. package/dist/mcp/transport-http.js +140 -0
  32. package/dist/mcp/transport-stdio.js +98 -0
  33. package/dist/mcp/types.js +11 -0
  34. package/dist/media/image.js +149 -0
  35. package/dist/{server.js → server/server.js} +1 -1
  36. package/dist/{cost.js → session/cost.js} +1 -1
  37. package/dist/{session.js → session/session.js} +1 -1
  38. package/dist/{version.js → shared/version.js} +1 -1
  39. package/dist/tools/apply-patch.js +62 -0
  40. package/dist/tools/edit-file.js +29 -0
  41. package/dist/tools/file-edit-common.js +124 -0
  42. package/dist/tools/list-files.js +64 -0
  43. package/dist/tools/path-safety.js +75 -0
  44. package/dist/tools/read-file.js +64 -0
  45. package/dist/tools/registry.js +297 -0
  46. package/dist/tools/run-command.js +192 -0
  47. package/dist/tools/schemas.js +8 -0
  48. package/dist/tools/search.js +237 -0
  49. package/dist/tools/task-tool.js +48 -0
  50. package/dist/tools/types.js +1 -0
  51. package/dist/tools/write-file.js +34 -0
  52. package/dist/tui/app.js +59 -532
  53. package/dist/tui/ask-user-modal.js +2 -1
  54. package/dist/tui/at-complete.js +2 -93
  55. package/dist/tui/charset.js +41 -0
  56. package/dist/tui/chat-views.js +72 -16
  57. package/dist/tui/chrome.js +11 -4
  58. package/dist/tui/hooks/use-command-host.js +26 -0
  59. package/dist/tui/hooks/use-event-queue.js +69 -0
  60. package/dist/tui/hooks/use-keybinds.js +87 -0
  61. package/dist/tui/hooks/use-scroll.js +38 -0
  62. package/dist/tui/hooks/use-session-resume.js +47 -0
  63. package/dist/tui/hooks/use-submit.js +106 -0
  64. package/dist/tui/image.js +2 -149
  65. package/dist/tui/input.js +1 -1
  66. package/dist/tui/markdown/ast.js +5 -0
  67. package/dist/tui/markdown/diagram.js +239 -0
  68. package/dist/tui/markdown/inline-render.js +24 -0
  69. package/dist/tui/markdown/inline.js +93 -0
  70. package/dist/tui/markdown/parse.js +139 -0
  71. package/dist/tui/markdown/render.js +146 -0
  72. package/dist/tui/markdown/table.js +106 -0
  73. package/dist/tui/markdown.js +32 -379
  74. package/dist/tui/permission-modal.js +2 -1
  75. package/dist/tui/selectors.js +157 -0
  76. package/dist/tui/session-picker.js +4 -3
  77. package/dist/tui/sidebar.js +130 -15
  78. package/dist/tui/state.js +47 -118
  79. package/dist/tui/text-width.js +139 -0
  80. package/dist/tui/theme.js +7 -2
  81. package/dist/{build.js → workspace/build.js} +1 -1
  82. package/dist/workspace/file-index.js +93 -0
  83. package/package.json +5 -1
  84. package/dist/tools.js +0 -1223
  85. package/dist/tui/command-registry.js +0 -477
  86. package/dist/tui/review.js +0 -64
  87. package/dist/tui/transcript.js +0 -145
  88. /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
  89. /package/dist/{agents.js → context/agents.js} +0 -0
  90. /package/dist/{compaction.js → context/compaction.js} +0 -0
  91. /package/dist/{context.js → context/context.js} +0 -0
  92. /package/dist/{memory.js → context/memory.js} +0 -0
  93. /package/dist/{repo-map.js → context/repo-map.js} +0 -0
  94. /package/dist/{ui.js → io/terminal.js} +0 -0
  95. /package/dist/{llm.js → llm/llm.js} +0 -0
  96. /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
  97. /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
  98. /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
  99. /package/dist/{retry.js → llm/retry.js} +0 -0
  100. /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
  101. /package/dist/{permissions.js → security/permissions.js} +0 -0
  102. /package/dist/{sandbox.js → security/sandbox.js} +0 -0
  103. /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
  104. /package/dist/{logger.js → session/logger.js} +0 -0
  105. /package/dist/{steering.js → session/steering.js} +0 -0
  106. /package/dist/{constants.js → shared/constants.js} +0 -0
  107. /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
  108. /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
  109. /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
  110. /package/dist/{diff.js → workspace/diff.js} +0 -0
  111. /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
  112. /package/dist/{watch.js → workspace/watch.js} +0 -0
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { theme } from "./theme.js";
5
+ import { boxBorderStyle } from "./charset.js";
5
6
  /** Modal for ask_user tool — single/multi choice + free text. */
6
7
  export function AskUserModal({ question, options, multiple, onAnswer, onCancel }) {
7
8
  const [input, setInput] = useState("");
@@ -21,5 +22,5 @@ export function AskUserModal({ question, options, multiple, onAnswer, onCancel }
21
22
  if (ch && !key.ctrl && !key.meta)
22
23
  setInput((v) => v + ch);
23
24
  });
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
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), 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
26
  }
@@ -1,93 +1,2 @@
1
- /**
2
- * T-01: `@` fuzzy autocomplete engine — pure logic, TUI-agnostic (unit-testable).
3
- * Sources: file index (.gitignore-aware via `rg --files`, fallback walk) +
4
- * directory prefixes so `@src/` opens the subtree. Engine: fuzzysort (MIT).
5
- */
6
- import { spawnSync } from "node:child_process";
7
- import { readdirSync } from "node:fs";
8
- import { join, relative } from "node:path";
9
- import fuzzysort from "fuzzysort";
10
- const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
11
- let cache = null;
12
- /** File index for `workdir` (cached, TTL 5s; invalidateFileIndex on tool writes). */
13
- export function fileIndex(workdir) {
14
- if (cache && Date.now() - cache.built < 5_000)
15
- return cache.files;
16
- const files = [];
17
- // Fast path: ripgrep --files (gitignore-aware). Bare-name globs match at any
18
- // depth; `!**/x/**` does NOT match absolute paths (I-07 lesson).
19
- try {
20
- const r = spawnSync("rg", ["--files", "--hidden", "-g", "!.git", "-g", "!node_modules", "-g", "!dist", "-g", "!build", "-g", "!.venv", "-g", "!__pycache__", "-g", "!.next", "--no-messages", workdir], { maxBuffer: 16e6, timeout: 3_000 });
21
- if (r.status === 0) {
22
- for (const line of r.stdout.toString().split("\n")) {
23
- if (line)
24
- files.push(relative(workdir, line) || line);
25
- }
26
- }
27
- }
28
- catch {
29
- // no rg on PATH → walker fallback
30
- }
31
- if (files.length === 0)
32
- walkFiles(workdir, "", files, 0);
33
- files.sort();
34
- cache = { files, built: Date.now() };
35
- return files;
36
- }
37
- /** Cache invalidate — call after write/edit tool_result (scope: whole index). */
38
- export function invalidateFileIndex() {
39
- cache = null;
40
- }
41
- function walkFiles(root, prefix, out, depth) {
42
- if (depth > 6)
43
- return;
44
- let entries;
45
- try {
46
- entries = readdirSync(root, { withFileTypes: true });
47
- }
48
- catch {
49
- return;
50
- }
51
- for (const e of entries) {
52
- if (SKIP.has(e.name))
53
- continue;
54
- const rel = prefix ? `${prefix}/${e.name}` : e.name;
55
- if (e.isDirectory())
56
- walkFiles(join(root, e.name), rel, out, depth + 1);
57
- else if (e.isFile())
58
- out.push(rel);
59
- }
60
- }
61
- /** Active `@` token in `value`, or null. Returns start index (of "@") + query. */
62
- export function atQuery(value) {
63
- const m = /(^|\s)@([^\s@]*)$/.exec(value);
64
- if (!m)
65
- return null;
66
- return { start: m.index + m[1].length, query: m[2] };
67
- }
68
- /** Candidate suggestions for a query: fuzzy files + dir prefixes, max 8. */
69
- export function suggest(files, query, limit = 8) {
70
- const dirs = new Set();
71
- for (const f of files) {
72
- const parts = f.split("/");
73
- for (let i = 1; i < parts.length; i++)
74
- dirs.add(parts.slice(0, i).join("/"));
75
- }
76
- const all = [...files, ...[...dirs].map((d) => `${d}/`)];
77
- let hits;
78
- if (query.includes("/") || query.endsWith("/")) {
79
- // Subtree mode: prefix match on directories (`@src/tui/…`).
80
- hits = all.filter((t) => t.startsWith(query));
81
- }
82
- else if (query === "") {
83
- hits = all.slice(0, limit * 4);
84
- }
85
- else {
86
- hits = fuzzysort.go(query, all, { limit: limit * 4 }).map((r) => r.target);
87
- }
88
- return hits.slice(0, limit);
89
- }
90
- /** Replace the active @token with `sel` (dirs keep the trailing slash). */
91
- export function applySuggestion(value, at, sel) {
92
- return value.slice(0, at.start) + "@" + sel + (sel.endsWith("/") ? "" : " ");
93
- }
1
+ /** Legacy compatibility facade; implementation lives in src/file-index.ts. */
2
+ export * from "../workspace/file-index.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Drawing charset — every box-drawing / block / marker glyph the TUI paints,
3
+ * in one place, with an ASCII-only fallback for terminals without Unicode
4
+ * (TERM=dumb, legacy Windows consoles, some CI logs).
5
+ *
6
+ * ASCII mode activates when SCODER_ASCII=1 or TERM=dumb. Colors are NOT
7
+ * handled here: Ink/chalk already respect NO_COLOR and FORCE_COLOR.
8
+ */
9
+ const UNICODE = {
10
+ tl: "┌", tr: "┐", bl: "└", br: "┘",
11
+ h: "─", v: "│",
12
+ teeDown: "┬", teeUp: "┴", teeRight: "├", teeLeft: "┤", cross: "┼",
13
+ blockFull: "█", blockEmpty: "░", barMarker: "▌",
14
+ bullet: "•", taskDone: "☑", taskTodo: "☐",
15
+ ok: "✓", fail: "✗", active: "◆", pending: "○",
16
+ cont: "↪",
17
+ spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
18
+ };
19
+ const ASCII = {
20
+ tl: "+", tr: "+", bl: "+", br: "+",
21
+ h: "-", v: "|",
22
+ teeDown: "+", teeUp: "+", teeRight: "+", teeLeft: "+", cross: "+",
23
+ blockFull: "#", blockEmpty: ".", barMarker: "|",
24
+ bullet: "*", taskDone: "[x]", taskTodo: "[ ]",
25
+ ok: "v", fail: "x", active: ">", pending: "o",
26
+ cont: ">",
27
+ spinner: ["-", "\\", "|", "/"],
28
+ };
29
+ export function isAsciiMode() {
30
+ return process.env.SCODER_ASCII === "1" || process.env.TERM === "dumb";
31
+ }
32
+ /** Active charset — resolved once at module load (env is static per process). */
33
+ export const charset = isAsciiMode() ? ASCII : UNICODE;
34
+ /** Ink borderStyle for code/diff boxes: "classic" is the ASCII-safe style. */
35
+ export function boxBorderStyle() {
36
+ return isAsciiMode() ? "classic" : "round";
37
+ }
38
+ /** Ink borderStyle for one-sided divider rules (app bars): thin, ASCII-safe. */
39
+ export function dividerBorderStyle() {
40
+ return isAsciiMode() ? "classic" : "single";
41
+ }
@@ -4,12 +4,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  * ChatItemView. Tách khỏi app.tsx (component-based, pattern từ opencode
5
5
  * packages/tui).
6
6
  */
7
- import { useEffect, useMemo, useState } from "react";
7
+ import { memo, useEffect, useMemo, useState } from "react";
8
8
  import { Box, Text } from "ink";
9
9
  import { Markdown } from "./markdown.js";
10
10
  import { detectImageCaps, halfblockRows } from "./image.js"; // T7
11
11
  import { AgentMark, fit, theme, toolIcon } from "./theme.js";
12
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ import { clipCells } from "./text-width.js";
13
+ import { boxBorderStyle, charset } from "./charset.js";
14
+ const SPINNER_FRAMES = charset.spinner;
13
15
  const SPINNER_MS = 80;
14
16
  function useSpinner(active) {
15
17
  const [frame, setFrame] = useState(0);
@@ -30,7 +32,7 @@ export function Spinner({ active }) {
30
32
  const frame = useSpinner(active);
31
33
  if (!active)
32
34
  return null;
33
- return _jsx(Text, { color: "cyan", children: frame });
35
+ return _jsx(Text, { color: theme.accent, children: frame });
34
36
  }
35
37
  const OUTPUT_PREVIEW_LINES = 5;
36
38
  /** T7: decode data URL → halfblock rows (null khi không PNG/không decode được). */
@@ -76,18 +78,18 @@ function ToolItemView({ item, width }) {
76
78
  ? `${(elapsedMs / 1000).toFixed(0)}s`
77
79
  : `${(elapsedMs / 1000).toFixed(1)}s`
78
80
  : null;
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] }))] }));
81
+ 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: [" └ ", clipCells(line, outputWidth)] }, 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] }))] }));
80
82
  }
81
- /** One colored line of a unified diff (+ green, - red, @@ cyan, header bold). */
83
+ /** One colored line of a unified diff (+ success, - error, @@ accent, header bold). */
82
84
  export function DiffLine({ line }) {
83
85
  if (line.startsWith("+++") || line.startsWith("---"))
84
86
  return _jsx(Text, { bold: true, children: line });
85
87
  if (line.startsWith("@@"))
86
- return _jsx(Text, { color: "cyan", children: line });
88
+ return _jsx(Text, { color: theme.accent, children: line });
87
89
  if (line.startsWith("+"))
88
- return _jsx(Text, { color: "green", children: line });
90
+ return _jsx(Text, { color: theme.success, children: line });
89
91
  if (line.startsWith("-"))
90
- return _jsx(Text, { color: "red", children: line });
92
+ return _jsx(Text, { color: theme.error, children: line });
91
93
  return _jsx(Text, { children: line });
92
94
  }
93
95
  const MAX_DIFF_RENDER_LINES = 20;
@@ -103,22 +105,76 @@ function DiffItemView({ item, width }) {
103
105
  const diffWidth = Math.max(24, (width ?? 100) - 6);
104
106
  const addedColor = item.added > 0 ? theme.success : undefined;
105
107
  const removedColor = item.removed > 0 ? theme.error : undefined;
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"] }))] }));
108
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), 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: clipCells(line, diffWidth) }, i))), hidden > 0 && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", hidden, " more diff lines"] }))] }));
107
109
  }
108
110
  /**
109
111
  * Plan KHÔNG còn render trong chat — checklist live hiển thị ở Sidebar bên
110
- * phải (sidebar.tsx, PlanSection) để transcript sạch; todo items vẫn được
111
- * giữ trong state (latestPlan) làm nguồn dữ liệu cho sidebar.
112
+ * phải (sidebar.tsx, PlanSection) để transcript sạch; plan hiển thị từ
113
+ * state.plan (field riêng, miễn nhiễm MAX_ITEMS eviction trên run dài).
112
114
  */
113
115
  function TodoItemView() {
114
116
  return null;
115
117
  }
116
- export function ChatItemView({ item, width }) {
118
+ /**
119
+ * Field-level equality for ChatItem — defense-in-depth cho React.memo.
120
+ * state.ts cập nhật item theo kiểu immutable (patchItem thay object trong
121
+ * array): item đổi nội dung ⇒ đổi reference ⇒ memo thấy khác và re-render.
122
+ * (Regression cũ: mutate in-place + memo ⇒ prev/next là CÙNG object ⇒ mọi
123
+ * so sánh đều "bằng" ⇒ stream đóng băng ở chunk đầu, tool kẹt "running".)
124
+ */
125
+ function chatItemEqual(a, b) {
126
+ if (a === b && a.kind !== "assistant" && a.kind !== "thinking" && a.kind !== "tool")
127
+ return true;
128
+ if (a.kind !== b.kind)
129
+ return false;
130
+ switch (a.kind) {
131
+ case "user":
132
+ return a.text === b.text;
133
+ case "assistant":
134
+ case "thinking": {
135
+ const bb = b;
136
+ return a.text === bb.text && a.streaming === bb.streaming;
137
+ }
138
+ case "tool": {
139
+ const bb = b;
140
+ return a.name === bb.name && a.ok === bb.ok && a.output === bb.output
141
+ && a.step === bb.step && a.durationMs === bb.durationMs && a.image === bb.image;
142
+ }
143
+ case "diff": {
144
+ const bb = b;
145
+ return a.text === bb.text && a.path === bb.path && a.added === bb.added && a.removed === bb.removed;
146
+ }
147
+ case "todo": {
148
+ const bb = b;
149
+ return a.items === bb.items;
150
+ }
151
+ case "build": {
152
+ const bb = b;
153
+ return a.success === bb.success && a.command === bb.command;
154
+ }
155
+ case "compaction": {
156
+ const bb = b;
157
+ return a.tokensBefore === bb.tokensBefore && a.tokensAfter === bb.tokensAfter && a.manual === bb.manual;
158
+ }
159
+ case "notice": {
160
+ const bb = b;
161
+ return a.text === bb.text && a.level === bb.level;
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * Memoized: during streaming, events flush at ~20 fps and previously forced
167
+ * EVERY chat item to re-render (O(n²) over a long transcript). Old finalized
168
+ * items now skip rendering entirely; only the item actually changed by the
169
+ * last event batch re-renders.
170
+ */
171
+ export const ChatItemView = memo(function ChatItemView({ item, width }) {
117
172
  switch (item.kind) {
118
173
  case "user":
119
- // User messages stand out with a full colored border + bold label so they're
120
- // immediately visible at a glance, distinct from the agent's ◆ markdown.
121
- return (_jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Box, { borderStyle: "single", borderColor: theme.user, borderLeft: true, borderRight: false, borderTop: false, borderBottom: false, backgroundColor: "gray", width: "100%", paddingX: 1, paddingTop: 1, paddingBottom: 1, flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "white", bold: true, children: "you \u25B8 " }), _jsx(Text, { color: "white", bold: true, children: item.text })] }) }) }));
174
+ // User messages stand out with a clean left accent border (│) + bold
175
+ // label — no full-width background block (design-system: subtle,
176
+ // scannable, không phá nhịp đọc của transcript).
177
+ return (_jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsx(Box, { borderStyle: "single", borderColor: theme.user, borderLeft: true, borderRight: false, borderTop: false, borderBottom: false, width: "100%", paddingX: 1, flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: theme.user, bold: true, children: "you \u25B8 " }), _jsx(Text, { bold: true, children: item.text })] }) }) }));
122
178
  case "assistant": {
123
179
  // Streaming: dim with pulsing cursor; finalized: markdown with agent mark.
124
180
  if (item.streaming || !item.text) {
@@ -150,4 +206,4 @@ export function ChatItemView({ item, width }) {
150
206
  item.level === "warn" ? theme.warn :
151
207
  theme.dim, children: [item.level === "error" ? "✗ " : item.level === "warn" ? "⚠ " : " ", item.text.length > 2000 ? item.text.slice(0, 2000) + "…" : item.text] }) }));
152
208
  }
153
- }
209
+ }, (prev, next) => prev.width === next.width && chatItemEqual(prev.item, next.item));
@@ -2,12 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useWindowSize } from "ink";
3
3
  import { fit, theme } from "./theme.js";
4
4
  import { Spinner } from "./chat-views.js";
5
+ import { getVersion } from "../shared/version.js";
6
+ import { dividerBorderStyle } from "./charset.js";
5
7
  /** Fuel gauge is scaled to this many cells regardless of maxSteps. */
6
8
  export const GAUGE_CELLS = 12;
7
9
  /** Sidebar phải: tổng rộng (kể cả border + padding) — pattern opencode. */
8
- export const SIDEBAR_WIDTH = 40;
10
+ export const SIDEBAR_WIDTH = 48;
9
11
  /** Dưới ngưỡng cột này ẩn sidebar — BuildRail fallback sang header đầy đủ. */
10
- export const SIDEBAR_MIN_COLS = 92;
12
+ export const SIDEBAR_MIN_COLS = 100;
11
13
  /** Scaled step fuel gauge: ▰ filled / ▱ remaining, e.g. ▰▰▰▰▱▱▱▱ 4/40. */
12
14
  export function fuelGauge(step, maxSteps) {
13
15
  if (maxSteps <= 0)
@@ -45,7 +47,10 @@ export function BuildRail({ state, config }) {
45
47
  const centerBudget = Math.max(24, (columns ?? 100) - 34);
46
48
  // Phase breadcrumb or narrow-terminal metrics (right of the center)
47
49
  const showNarrowMetrics = !wide && (state.status === "running" || state.phases.length > 0);
48
- return (_jsxs(Box, { borderStyle: "bold", borderColor: theme.border, paddingX: 1, justifyContent: "space-between", alignItems: "center", flexShrink: 0, children: [_jsxs(Text, { bold: true, color: theme.brand, children: ["\u2691 ", _jsx(Text, { bold: true, children: "scoder" })] }), _jsx(Box, { flexGrow: 1, justifyContent: "center", children: hasGuardrail ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u26A0 ", fit(state.lastGuardrail, centerBudget)] })) : state.status === "running" ? (showNarrowMetrics ? (_jsx(PhaseBreadcrumb, { phases: state.phases })) : (_jsx(Text, { dimColor: true, children: "plan \u203A explore \u203A edit \u203A build \u203A verify" }))) : (_jsx(Text, { dimColor: true, children: "ready" })) }), _jsxs(Text, { children: [_jsxs(Text, { color: permColor, children: ["[", perm, "]"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: lampColor, children: lampLabel }), _jsxs(Text, { dimColor: true, children: [" ", lampText] })] })] }));
50
+ return (
51
+ // App-bar style: chỉ 1 divider mảnh dưới đáy thay vì khung "bold" nặng
52
+ // 3 hàng — tiết kiệm 1 dòng dọc và cho cảm giác hiện đại hơn.
53
+ _jsxs(Box, { borderStyle: dividerBorderStyle(), borderColor: theme.border, borderTop: false, borderLeft: false, borderRight: false, paddingX: 1, justifyContent: "space-between", alignItems: "center", flexShrink: 0, children: [_jsxs(Text, { bold: true, color: theme.brand, children: ["\u2691 ", _jsx(Text, { bold: true, children: "scoder" }), _jsxs(Text, { dimColor: true, children: [" (v", getVersion(), ")"] })] }), _jsx(Box, { flexGrow: 1, justifyContent: "center", children: hasGuardrail ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u26A0 ", fit(state.lastGuardrail, centerBudget)] })) : state.status === "running" ? (showNarrowMetrics ? (_jsx(PhaseBreadcrumb, { phases: state.phases })) : (_jsx(Text, { dimColor: true, children: "plan \u203A explore \u203A edit \u203A build \u203A verify" }))) : (_jsx(Text, { dimColor: true, children: "ready" })) }), _jsxs(Text, { children: [_jsxs(Text, { color: permColor, children: ["[", perm, "]"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: lampColor, children: lampLabel }), _jsxs(Text, { dimColor: true, children: [" ", lampText] })] })] }));
49
54
  }
50
55
  /** Breadcrumb tiến trình: plan › explore › edit › build › verify. */
51
56
  const PHASE_SEQUENCE = ["plan", "explore", "edit", "build", "verify"];
@@ -94,5 +99,7 @@ export function StatusBar({ state, scroll }) {
94
99
  else
95
100
  left = fit(left, leftBudget);
96
101
  }
97
- return (_jsxs(Box, { borderStyle: "bold", borderColor: theme.border, paddingX: 1, justifyContent: "space-between", alignItems: "center", flexShrink: 0, children: [_jsxs(Text, { children: [state.status === "running" && _jsx(Spinner, { active: true }), state.status === "running" ? " " : statusIcon ? `${statusIcon} ` : " ", _jsx(Text, { color: statusColor, bold: true, children: left })] }), _jsx(Text, { dimColor: true, children: fit(rightText, Math.floor(width * 0.5)) })] }));
102
+ return (
103
+ // App-bar style: chỉ 1 divider mảnh trên đỉnh, đối xứng với BuildRail.
104
+ _jsxs(Box, { borderStyle: dividerBorderStyle(), borderColor: theme.border, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1, justifyContent: "space-between", alignItems: "center", flexShrink: 0, children: [_jsxs(Text, { children: [state.status === "running" && _jsx(Spinner, { active: true }), state.status === "running" ? " " : statusIcon ? `${statusIcon} ` : " ", _jsx(Text, { color: statusColor, bold: true, children: left })] }), _jsx(Text, { dimColor: true, children: fit(rightText, Math.floor(width * 0.5)) })] }));
98
105
  }
@@ -0,0 +1,26 @@
1
+ /** T1: CommandHost cho registry dùng chung với REPL. */
2
+ import { useCallback } from "react";
3
+ import { initialState } from "../state.js";
4
+ export function useCommandHost(deps) {
5
+ const { cfg, setCfg, update, agentRef, runningRef, eventQueue, setScrollOffset, setPermMode } = deps;
6
+ /** T1: CommandHost cho registry dùng chung với REPL. */
7
+ const tuiHost = useCallback(() => ({
8
+ agent: () => agentRef.current,
9
+ config: cfg,
10
+ updateConfig: (patch) => setCfg((c) => ({ ...c, ...patch })),
11
+ isRunning: () => runningRef.current,
12
+ replaceAgent: (next) => {
13
+ agentRef.current?.abort();
14
+ agentRef.current?.dispose();
15
+ agentRef.current = next;
16
+ if (!next) {
17
+ runningRef.current = false;
18
+ setScrollOffset(0);
19
+ eventQueue.current = [];
20
+ update(() => initialState());
21
+ }
22
+ setPermMode(cfg.permissionMode);
23
+ },
24
+ }), [cfg, update]);
25
+ return tuiHost;
26
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Event batching + the synchronous stateRef mirror for the TUI. Extracted
3
+ * verbatim from app.tsx — flush timing (FLUSH_MS) and reducer order unchanged.
4
+ */
5
+ import { useCallback, useEffect, useRef } from "react";
6
+ import { applyEvent } from "../state.js";
7
+ export function useEventQueue(initial, setState, setPermPrompt, runningRef) {
8
+ const stateRef = useRef(initial);
9
+ /**
10
+ * Central state updater — the single source of truth is stateRef, updated
11
+ * synchronously so event handlers never race a render cycle.
12
+ */
13
+ const update = useCallback((fn) => {
14
+ stateRef.current = fn(stateRef.current);
15
+ setState(stateRef.current);
16
+ }, []);
17
+ /**
18
+ * Event batching: streaming (`llm_delta`) and live tool output (`tool_output`)
19
+ * can emit hundreds of events per second; each one previously triggered a
20
+ * full-tree re-render (the flicker). Buffer incoming events and flush them
21
+ * through the reducer at most every FLUSH_MS (~20 fps, well above what the
22
+ * eye needs for streaming text).
23
+ */
24
+ const FLUSH_MS = 50;
25
+ const eventQueue = useRef([]);
26
+ const flushTimer = useRef(null);
27
+ const flushEvents = useCallback(() => {
28
+ flushTimer.current = null;
29
+ const events = eventQueue.current;
30
+ if (events.length === 0)
31
+ return;
32
+ eventQueue.current = [];
33
+ update((s) => {
34
+ let next = s;
35
+ for (const ev of events)
36
+ next = applyEvent(next, ev);
37
+ return { ...next, items: [...next.items] };
38
+ });
39
+ }, [update]);
40
+ // Realtime tick: re-render at least once per second while a run is active so
41
+ // sidebar metrics (elapsed time, ctx pressure, tok/s during/after a stream)
42
+ // update live instead of only on the next event/command boundary.
43
+ useEffect(() => {
44
+ const id = setInterval(() => {
45
+ if (!runningRef.current)
46
+ return;
47
+ update((s) => ({ ...s }));
48
+ }, 1000);
49
+ return () => clearInterval(id);
50
+ }, [update]);
51
+ const queueEvent = useCallback((ev) => {
52
+ // T-03: permission modal must appear instantly (not batched) — the run
53
+ // blocks on the answer.
54
+ if (ev.type === "permission_request") {
55
+ setPermPrompt(ev);
56
+ return;
57
+ }
58
+ eventQueue.current.push(ev);
59
+ if (!flushTimer.current)
60
+ flushTimer.current = setTimeout(flushEvents, FLUSH_MS);
61
+ }, [flushEvents]);
62
+ // Flush on unmount so a trailing burst is never dropped.
63
+ useEffect(() => () => {
64
+ if (flushTimer.current)
65
+ clearTimeout(flushTimer.current);
66
+ flushEvents();
67
+ }, [flushEvents]);
68
+ return { stateRef, update, queueEvent, eventQueue };
69
+ }
@@ -0,0 +1,87 @@
1
+ /** Global keybinds: permission modal answers, scroll/abort, Ctrl+C confirm-quit. */
2
+ import { useInput } from "ink";
3
+ import { useEffect, useRef } from "react";
4
+ import { markAborted } from "../state.js";
5
+ export function useTuiKeybinds(deps) {
6
+ const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, exit } = deps;
7
+ // Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
8
+ // the first Esc returns to the bottom instead.
9
+ const scrollOffsetRef = useRef(0);
10
+ useEffect(() => {
11
+ scrollOffsetRef.current = scrollOffset;
12
+ }, [scrollOffset]);
13
+ // T-03: permission modal keybinds — intercepts y/a/n/q while open.
14
+ useInput((input, key) => {
15
+ const p = permPromptRef.current;
16
+ if (!p)
17
+ return;
18
+ const answer = key.return ? "yes" : input.toLowerCase() === "y" ? "yes" : input.toLowerCase() === "a" ? "always" : input.toLowerCase() === "n" ? "no" : input.toLowerCase() === "q" ? "quit" : key.escape ? "no" : null;
19
+ if (!answer)
20
+ return;
21
+ setPermPrompt(null);
22
+ agentRef.current?.resolvePermission(p.id, answer);
23
+ });
24
+ // T-01/T-02: scroll + abort keybinds.
25
+ useInput((_input, key) => {
26
+ if (permPromptRef.current)
27
+ return; // modal owns the keyboard
28
+ // ── Ctrl+S: open session picker (when not running, not already open) ──
29
+ if (key.ctrl && _input === "s") {
30
+ if (!runningRef.current && !sessionPickerOpen) {
31
+ setSessionPickerOpen(true);
32
+ }
33
+ return;
34
+ }
35
+ // ── Ctrl+P: command palette ──
36
+ if (key.ctrl && _input === "p") {
37
+ setPaletteOpen((o) => !o);
38
+ return;
39
+ }
40
+ // ── ?: help overlay (when input empty) ──
41
+ if (_input === "?" && !inputRef.current.trim()) {
42
+ setHelpOpen((o) => !o);
43
+ return;
44
+ }
45
+ // T-10: ctrl+t toggles the thinking stream visibility.
46
+ if (key.ctrl && _input === "t") {
47
+ update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
48
+ return;
49
+ }
50
+ if (key.pageUp || key.pageDown || key.home || key.end) {
51
+ const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
52
+ if (key.pageUp)
53
+ setScrollOffset((o) => o + page);
54
+ if (key.pageDown)
55
+ setScrollOffset((o) => Math.max(0, o - page));
56
+ if (key.home)
57
+ setScrollOffset(999_999); // top (clamped to maxScroll in render)
58
+ if (key.end)
59
+ setScrollOffset(0); // bottom = follow
60
+ return;
61
+ }
62
+ if (key.escape) {
63
+ if (scrollOffsetRef.current > 0) {
64
+ setScrollOffset(0);
65
+ return;
66
+ }
67
+ if (runningRef.current) {
68
+ update((s) => ({ ...markAborted(s), items: [...s.items] }));
69
+ agentRef.current?.abort();
70
+ }
71
+ }
72
+ });
73
+ // Ctrl+C quits (press again within 2s to confirm while running).
74
+ const ctrlCArmed = useRef(null);
75
+ useInput((_input, key) => {
76
+ if (!key.ctrl || _input !== "c")
77
+ return;
78
+ if (runningRef.current && ctrlCArmed.current === null) {
79
+ ctrlCArmed.current = Date.now();
80
+ update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
81
+ setTimeout(() => (ctrlCArmed.current = null), 2000);
82
+ return;
83
+ }
84
+ agentRef.current?.abort();
85
+ exit();
86
+ });
87
+ }
@@ -0,0 +1,38 @@
1
+ /** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI. */
2
+ import { useCallback, useEffect, useRef } from "react";
3
+ import { useBoxMetrics, useStdin } from "ink";
4
+ import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "../mouse.js";
5
+ export function useScrollMetrics(setScrollOffset) {
6
+ const viewportRef = useRef(null);
7
+ const contentRef = useRef(null);
8
+ const viewport = useBoxMetrics(viewportRef);
9
+ const content = useBoxMetrics(contentRef);
10
+ const viewportRowsRef = useRef(null);
11
+ useEffect(() => {
12
+ viewportRowsRef.current = viewport.clientHeight || null;
13
+ }, [viewport.clientHeight]);
14
+ // T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
15
+ // drops unrepresentable CSI sequences, so wheel events must be read here.
16
+ // Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
17
+ const { stdin, setRawMode, isRawModeSupported } = useStdin();
18
+ const wheelScroll = useCallback((dir) => {
19
+ setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
20
+ }, []);
21
+ useEffect(() => {
22
+ if (process.stdout.isTTY !== true || !isRawModeSupported)
23
+ return;
24
+ process.stdout.write(MOUSE_ENABLE);
25
+ setRawMode(true);
26
+ const onData = (chunk) => {
27
+ const dir = parseWheel(String(chunk));
28
+ if (dir)
29
+ wheelScroll(dir);
30
+ };
31
+ stdin.on("data", onData);
32
+ return () => {
33
+ stdin.off("data", onData);
34
+ process.stdout.write(MOUSE_DISABLE);
35
+ };
36
+ }, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
37
+ return { viewportRef, contentRef, viewport, content, viewportRowsRef };
38
+ }
@@ -0,0 +1,47 @@
1
+ /** Session picker resume flow — restores transcript state and resumes the run. */
2
+ import { useCallback } from "react";
3
+ import { resolveSessionId } from "../../session/session.js";
4
+ import { initialState } from "../state.js";
5
+ export function useSessionResume(deps) {
6
+ const { cfg, createAgent, pushNotice, update, runningRef, setScrollOffset, setSessionPickerOpen } = deps;
7
+ /** Session picker: when a session is selected, resume it. */
8
+ const handleSessionSelect = useCallback(async (session) => {
9
+ setSessionPickerOpen(false);
10
+ if (runningRef.current) {
11
+ pushNotice("agent is running — wait or Esc first", "warn");
12
+ return;
13
+ }
14
+ const s = resolveSessionId(cfg.workdir, session.id);
15
+ if (!s) {
16
+ pushNotice(`session not found: ${session.id}`, "error");
17
+ return;
18
+ }
19
+ // Restore full TUI state from the session (items + step count + model info).
20
+ update(() => ({
21
+ ...initialState(),
22
+ items: s.messages
23
+ .filter((m) => m.role === "user" || m.role === "assistant")
24
+ .map((m) => m.role === "user"
25
+ ? { kind: "user", text: typeof m.content === "string" ? m.content : "" }
26
+ : { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false }),
27
+ step: s.steps ?? 0,
28
+ maxSteps: s.maxSteps ?? cfg.maxSteps,
29
+ model: s.model || cfg.model,
30
+ provider: s.provider || cfg.provider,
31
+ status: "idle",
32
+ summary: null,
33
+ }));
34
+ runningRef.current = true;
35
+ setScrollOffset(0);
36
+ const agent = createAgent();
37
+ try {
38
+ await agent.resume(s);
39
+ runningRef.current = false;
40
+ }
41
+ catch (err) {
42
+ runningRef.current = false;
43
+ pushNotice(`resume failed: ${err.message}`, "error");
44
+ }
45
+ }, [cfg, createAgent, pushNotice, update]);
46
+ return handleSessionSelect;
47
+ }