s-app-scoder-agent 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +600 -468
- package/dist/agent.js +841 -197
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +423 -0
- package/dist/index.js +129 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +103 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +32 -2
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +505 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +146 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +99 -0
- package/dist/tui/state.js +192 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/tui/app.js
CHANGED
|
@@ -1,225 +1,477 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* Full-screen Ink TUI for scoder — "mission control for the build loop".
|
|
4
4
|
*
|
|
5
5
|
* Identity (deliberately distinct from chat-first agent UIs): the build is
|
|
6
6
|
* the mission, so the screen is organized around it —
|
|
7
|
-
* · Build Rail (top): brand, build
|
|
7
|
+
* · Build Rail (top): brand, model/permission badges, build lamp, fuel gauge
|
|
8
8
|
* · Flight-recorder log: each tool entry is stamped `07 ▸ name(args)`
|
|
9
9
|
* · `✎` diff cards rendered inline for every file edit
|
|
10
|
+
* · Assistant messages render as markdown (code fences, headers, lists)
|
|
11
|
+
* · Input: ↑/↓ history · Tab command completion · live candidates hint
|
|
10
12
|
* Driven entirely by AgentEvents (the same stream the HTTP/SSE server uses).
|
|
11
13
|
*/
|
|
12
14
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
-
import { Box, Text, useApp, useInput,
|
|
15
|
+
import { Box, Text, useApp, useBoxMetrics, useInput, useStdin, useWindowSize } from "ink";
|
|
14
16
|
import { Agent } from "../agent.js";
|
|
15
17
|
import { setQuiet } from "../ui.js";
|
|
18
|
+
import { discoverSkills } from "../skills.js";
|
|
19
|
+
import { discoverAgents } from "../agents.js";
|
|
20
|
+
import { resolveSessionId } from "../session.js";
|
|
16
21
|
import { applyEvent, initialState, markAborted } from "./state.js";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
function formatArgs(args) {
|
|
32
|
-
return Object.entries(args)
|
|
33
|
-
.map(([k, v]) => {
|
|
34
|
-
const s = typeof v === "string" ? v : JSON.stringify(v) ?? "";
|
|
35
|
-
return `${k}=${s.length > 60 ? s.slice(0, 60) + "…" : s.replace(/\n/g, " ")}`;
|
|
36
|
-
})
|
|
37
|
-
.join(" ");
|
|
38
|
-
}
|
|
39
|
-
/** Scaled step fuel gauge: ▰ filled / ▱ remaining, e.g. ▰▰▰▰▱▱▱▱ 4/40. */
|
|
40
|
-
function fuelGauge(step, maxSteps) {
|
|
41
|
-
if (maxSteps <= 0)
|
|
42
|
-
return null;
|
|
43
|
-
const filled = Math.min(GAUGE_CELLS, Math.round((Math.min(step, maxSteps) / maxSteps) * GAUGE_CELLS));
|
|
44
|
-
return "▰".repeat(filled) + "▱".repeat(GAUGE_CELLS - filled);
|
|
45
|
-
}
|
|
46
|
-
const OUTPUT_PREVIEW_LINES = 4;
|
|
47
|
-
function ToolItemView({ item, spinner }) {
|
|
48
|
-
const running = item.ok === null;
|
|
49
|
-
const icon = running ? _jsx(Text, { color: "cyan", children: spinner }) : item.ok ? _jsx(Text, { color: "green", children: "\u2714" }) : _jsx(Text, { color: "red", children: "\u2718" });
|
|
50
|
-
const lines = item.output.length > 0 ? item.output.replace(/\n+$/, "").split("\n") : [];
|
|
51
|
-
const preview = lines.slice(-OUTPUT_PREVIEW_LINES);
|
|
52
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8"] }), " ", icon, " ", _jsx(Text, { color: "cyan", children: item.name }), _jsxs(Text, { dimColor: true, children: ["(", formatArgs(item.args), ")"] }), running && _jsx(Text, { dimColor: true, children: " \u00B7 running\u2026" })] }), preview.map((line, i) => (_jsx(Text, { dimColor: i < preview.length - 1, children: line.length > 160 ? line.slice(0, 160) + "…" : line }, i)))] }));
|
|
53
|
-
}
|
|
54
|
-
/** One colored line of a unified diff (+ green, - red, @@ cyan, header bold). */
|
|
55
|
-
function DiffLine({ line }) {
|
|
56
|
-
if (line.startsWith("+++") || line.startsWith("---"))
|
|
57
|
-
return _jsx(Text, { bold: true, children: line });
|
|
58
|
-
if (line.startsWith("@@"))
|
|
59
|
-
return _jsx(Text, { color: "cyan", children: line });
|
|
60
|
-
if (line.startsWith("+"))
|
|
61
|
-
return _jsx(Text, { color: "green", children: line });
|
|
62
|
-
if (line.startsWith("-"))
|
|
63
|
-
return _jsx(Text, { color: "red", children: line });
|
|
64
|
-
return _jsx(Text, { children: line });
|
|
65
|
-
}
|
|
66
|
-
const MAX_DIFF_RENDER_LINES = 24;
|
|
67
|
-
function DiffItemView({ item }) {
|
|
68
|
-
const lines = item.text.length > 0 ? item.text.split("\n") : [];
|
|
69
|
-
const shown = lines.slice(0, MAX_DIFF_RENDER_LINES);
|
|
70
|
-
const hidden = lines.length - shown.length;
|
|
71
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8"] }), " ", _jsx(Text, { color: "magenta", bold: true, children: "\u270E" }), " ", _jsxs(Text, { color: "green", children: ["+", item.added] }), " ", _jsxs(Text, { color: "red", children: ["-", item.removed] }), " ", _jsx(Text, { bold: true, children: item.path }), item.truncated && _jsx(Text, { color: "yellow", children: " (diff truncated)" })] }), shown.map((line, i) => (_jsx(DiffLine, { line: line.length > 160 ? line.slice(0, 160) + "…" : line }, i))), hidden > 0 && _jsxs(Text, { dimColor: true, children: ["\u2026 +", hidden, " more diff lines"] })] }));
|
|
72
|
-
}
|
|
73
|
-
function ChatItemView({ item, spinner }) {
|
|
74
|
-
switch (item.kind) {
|
|
75
|
-
case "user":
|
|
76
|
-
return (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "blue", bold: true, children: ["\u276F", " "] }), _jsx(Text, { bold: true, children: item.text })] }));
|
|
77
|
-
case "assistant":
|
|
78
|
-
return (_jsx(Box, { marginTop: item.text ? 1 : 0, paddingLeft: 1, children: _jsx(Text, { dimColor: item.streaming, children: item.text || (item.streaming ? "" : "(no text)") }) }));
|
|
79
|
-
case "tool":
|
|
80
|
-
return _jsx(ToolItemView, { item: item, spinner: spinner });
|
|
81
|
-
case "diff":
|
|
82
|
-
return _jsx(DiffItemView, { item: item });
|
|
83
|
-
case "build":
|
|
84
|
-
return (_jsxs(Box, { paddingLeft: 1, children: [item.success ? _jsx(Text, { color: "green", bold: true, children: "\u2714 build passed" }) : _jsx(Text, { color: "red", bold: true, children: "\u2718 build failed" }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", item.command] })] }));
|
|
85
|
-
case "notice":
|
|
86
|
-
return (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: item.level === "error" ? "red" : item.level === "warn" ? "yellow" : "gray", children: [item.level === "error" ? "✘ " : "⚠ ", item.text.length > 500 ? item.text.slice(0, 500) + "…" : item.text] }) }));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
/** Build Rail — scoder's signature top bar: lamp + build command + fuel gauge. */
|
|
90
|
-
function BuildRail({ state, config }) {
|
|
91
|
-
const lampColor = state.buildLamp === "ok" ? "green" : state.buildLamp === "fail" ? "red" : "gray";
|
|
92
|
-
const lampLabel = state.buildLamp === "ok" ? "build ok" : state.buildLamp === "fail" ? "build fail" : "no build yet";
|
|
93
|
-
const gauge = fuelGauge(state.step, state.maxSteps);
|
|
94
|
-
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "magenta", children: "scoder" }), _jsxs(Text, { children: [_jsxs(Text, { color: lampColor, children: ["\u25CF ", lampLabel] }), _jsxs(Text, { dimColor: true, children: [" ", state.buildCmd || config.buildCmd || "auto"] })] }), _jsxs(Text, { children: [gauge && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "magenta", children: gauge }), _jsxs(Text, { dimColor: true, children: [" ", state.step, "/", state.maxSteps] })] })), !gauge && _jsx(Text, { dimColor: true, children: "\u2014 idle" })] })] }));
|
|
95
|
-
}
|
|
96
|
-
function StatusBar({ state, spinner }) {
|
|
97
|
-
const statusColor = state.status === "running" ? "cyan" : state.status === "success" ? "green" : state.status === "failed" ? "red" : "gray";
|
|
98
|
-
const statusLabel = state.status === "running" ? `running ${state.step}/${state.maxSteps}` : state.status === "idle" ? "idle" : state.status;
|
|
99
|
-
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: statusColor, children: state.status === "running" ? `${spinner} ${statusLabel}` : statusLabel }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.model || "—"] }), state.usage.calls > 0 && (_jsxs(Text, { dimColor: true, children: [" ", "\u00B7 tok ", state.usage.inputTokens, "/", state.usage.outputTokens, state.usage.cachedTokens > 0 ? ` (+${state.usage.cachedTokens} cached)` : ""] }))] }), _jsx(Text, { dimColor: true, children: "\u23CE send \u00B7 pgup/pgdn scroll \u00B7 esc abort \u00B7 ctrl+c quit" })] }));
|
|
100
|
-
}
|
|
101
|
-
function Input({ valueRef, value, onChange, onSubmit }) {
|
|
102
|
-
useInput((input, key) => {
|
|
103
|
-
const current = valueRef.current;
|
|
104
|
-
if (key.return) {
|
|
105
|
-
onSubmit(current);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (key.backspace || key.delete) {
|
|
109
|
-
const next = current.slice(0, -1);
|
|
110
|
-
valueRef.current = next;
|
|
111
|
-
onChange(next);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (input.length > 0 && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow && !key.tab) {
|
|
115
|
-
// A chunk may contain several pasted chars — even a trailing newline
|
|
116
|
-
// (terminal pastes / PTY writes deliver "exit\r" as one chunk).
|
|
117
|
-
const parts = input.split(/[\r\n]+/);
|
|
118
|
-
const next = current + parts[0];
|
|
119
|
-
valueRef.current = next;
|
|
120
|
-
onChange(next);
|
|
121
|
-
if (parts.length > 1)
|
|
122
|
-
onSubmit(next); // newline in the chunk = submit
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: "magenta", bold: true, children: "❯ " }), _jsx(Text, { children: value }), _jsx(Text, { dimColor: true, children: "\u258C" })] }));
|
|
126
|
-
}
|
|
127
|
-
export function TuiApp({ config, initialTask, bootNotice }) {
|
|
22
|
+
import { parseSlashCommand } from "./commands.js";
|
|
23
|
+
import { dispatchCommand } from "./command-registry.js";
|
|
24
|
+
import { ChatItemView } from "./chat-views.js"; // T2
|
|
25
|
+
import { BuildRail, StatusBar, SIDEBAR_MIN_COLS } from "./chrome.js"; // T2
|
|
26
|
+
import { Input } from "./input.js"; // T2
|
|
27
|
+
import { SessionPicker } from "./session-picker.js"; // session picker modal
|
|
28
|
+
import { fileIndex } from "./at-complete.js";
|
|
29
|
+
import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js"; // T5
|
|
30
|
+
import { getTheme, setActiveTheme, theme, THEMES, useNamedTheme } from "./theme.js";
|
|
31
|
+
import { reviewFromResponse, reviewAnswerToCall } from "./review.js"; // T2
|
|
32
|
+
import { PermissionModal } from "./permission-modal.js"; // T2
|
|
33
|
+
import { Sidebar } from "./sidebar.js"; // sidebar opencode
|
|
34
|
+
import { getVersion } from "../version.js";
|
|
35
|
+
export function TuiApp({ config, initialTask }) {
|
|
128
36
|
const { exit } = useApp();
|
|
129
|
-
|
|
37
|
+
// T-09: theme resolve at mount (.scoder/theme.json > builtin). Proxy-based
|
|
38
|
+
// theme reads re-render via a bump.
|
|
39
|
+
const [, bump] = useState(0);
|
|
40
|
+
const bumpFrame = useCallback(() => bump((n) => n + 1), []);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setActiveTheme(getTheme(config.workdir));
|
|
43
|
+
bumpFrame();
|
|
44
|
+
}, [config.workdir, bumpFrame]);
|
|
130
45
|
const [state, setState] = useState(initialState);
|
|
131
46
|
const [input, setInput] = useState(initialTask ?? "");
|
|
132
|
-
|
|
47
|
+
/** Rows scrolled up from the bottom; 0 = follow the newest content. */
|
|
48
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
49
|
+
const [permMode, setPermMode] = useState(config.permissionMode);
|
|
50
|
+
// T-03: active permission prompt (modal) — the run blocks until answered.
|
|
51
|
+
const [permPrompt, setPermPrompt] = useState(null);
|
|
52
|
+
const permPromptRef = useRef(permPrompt);
|
|
53
|
+
permPromptRef.current = permPrompt;
|
|
54
|
+
// Session picker modal state (open via Ctrl+S)
|
|
55
|
+
const [sessionPickerOpen, setSessionPickerOpen] = useState(false);
|
|
133
56
|
const inputRef = useRef(initialTask ?? "");
|
|
134
57
|
const writeInput = useCallback((v) => {
|
|
135
58
|
inputRef.current = v;
|
|
136
59
|
setInput(v);
|
|
137
60
|
}, []);
|
|
61
|
+
const historyRef = useRef([]);
|
|
138
62
|
const runningRef = useRef(false);
|
|
139
63
|
const agentRef = useRef(null);
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
64
|
+
// MCP connections live for the session (one connect, no per-message re-spawn).
|
|
65
|
+
// Dispose on unmount so child server processes never outlive the TUI.
|
|
66
|
+
useEffect(() => () => {
|
|
67
|
+
agentRef.current?.dispose();
|
|
68
|
+
agentRef.current = null;
|
|
69
|
+
}, []);
|
|
70
|
+
// ── Prompt-gate review flow (/review) ──────────────────────────────────────
|
|
71
|
+
// State machine relaying the universal-prompt-gate-mcp text-fallback flow:
|
|
72
|
+
// call → pending_level_selection → user picks level → pending_user_selection
|
|
73
|
+
// → user picks original/rewritten/update/cancel → approved/cancelled.
|
|
74
|
+
// `argsBase` holds the immutable prompt/context/language; `rewrittenPrompt`
|
|
75
|
+
// is echoed back verbatim from the server's pending result (never from the
|
|
76
|
+
// user or LLM) so only exactly what the user saw can be approved.
|
|
77
|
+
const reviewRef = useRef(null);
|
|
78
|
+
const reviewBusyRef = useRef(false);
|
|
79
|
+
// ── Scrollable chat viewport (Ink 8 alt-screen) ──
|
|
80
|
+
// The viewport clips overflow; the inner content wrapper keeps its natural
|
|
81
|
+
// height so it can be measured and offset upward for scrolling.
|
|
82
|
+
const viewportRef = useRef(null);
|
|
83
|
+
const contentRef = useRef(null);
|
|
84
|
+
const viewport = useBoxMetrics(viewportRef);
|
|
85
|
+
const content = useBoxMetrics(contentRef);
|
|
86
|
+
const viewportRowsRef = useRef(null);
|
|
144
87
|
useEffect(() => {
|
|
145
|
-
|
|
88
|
+
viewportRowsRef.current = viewport.clientHeight || null;
|
|
89
|
+
}, [viewport.clientHeight]);
|
|
90
|
+
const maxScroll = Math.max(0, content.height - viewport.clientHeight);
|
|
91
|
+
// scrollOffset counts rows UP from the bottom; 0 = follow the newest item.
|
|
92
|
+
const scrollTop = Math.max(0, maxScroll - scrollOffset);
|
|
93
|
+
// T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
|
|
94
|
+
// drops unrepresentable CSI sequences, so wheel events must be read here.
|
|
95
|
+
// Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
|
|
96
|
+
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
97
|
+
const wheelScroll = useCallback((dir) => {
|
|
98
|
+
setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
|
|
99
|
+
}, []);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (process.stdout.isTTY !== true || !isRawModeSupported)
|
|
102
|
+
return;
|
|
103
|
+
process.stdout.write(MOUSE_ENABLE);
|
|
104
|
+
setRawMode(true);
|
|
105
|
+
const onData = (chunk) => {
|
|
106
|
+
const dir = parseWheel(String(chunk));
|
|
107
|
+
if (dir)
|
|
108
|
+
wheelScroll(dir);
|
|
109
|
+
};
|
|
110
|
+
stdin.on("data", onData);
|
|
111
|
+
return () => {
|
|
112
|
+
stdin.off("data", onData);
|
|
113
|
+
process.stdout.write(MOUSE_DISABLE);
|
|
114
|
+
};
|
|
115
|
+
}, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
|
|
116
|
+
const skills = useMemo(() => discoverSkills(config.workdir), [config.workdir]);
|
|
117
|
+
const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
|
|
118
|
+
// T-02: completion sources (agents + files) computed once per mount.
|
|
119
|
+
const atSources = useMemo(() => ({
|
|
120
|
+
skills: skillNames,
|
|
121
|
+
agents: discoverAgents(config.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name),
|
|
122
|
+
files: fileIndex(config.workdir),
|
|
123
|
+
}), [config.workdir, skillNames]);
|
|
124
|
+
// Mirror of state for the submit path (isFirst, /cost, /model) and the
|
|
125
|
+
// abort handler — kept in sync synchronously by update() below, so it is
|
|
126
|
+
// exact even between renders (event bursts land between them).
|
|
127
|
+
const stateRef = useRef(state);
|
|
128
|
+
/**
|
|
129
|
+
* Central state updater — the single source of truth is stateRef, updated
|
|
130
|
+
* synchronously so event handlers never race a render cycle.
|
|
131
|
+
*/
|
|
132
|
+
const update = useCallback((fn) => {
|
|
133
|
+
stateRef.current = fn(stateRef.current);
|
|
134
|
+
setState(stateRef.current);
|
|
135
|
+
}, []);
|
|
136
|
+
/**
|
|
137
|
+
* Event batching: streaming (`llm_delta`) and live tool output (`tool_output`)
|
|
138
|
+
* can emit hundreds of events per second; each one previously triggered a
|
|
139
|
+
* full-tree re-render (the flicker). Buffer incoming events and flush them
|
|
140
|
+
* through the reducer at most every FLUSH_MS (~20 fps, well above what the
|
|
141
|
+
* eye needs for streaming text).
|
|
142
|
+
*/
|
|
143
|
+
const FLUSH_MS = 50;
|
|
144
|
+
const eventQueue = useRef([]);
|
|
145
|
+
const flushTimer = useRef(null);
|
|
146
|
+
const flushEvents = useCallback(() => {
|
|
147
|
+
flushTimer.current = null;
|
|
148
|
+
const events = eventQueue.current;
|
|
149
|
+
if (events.length === 0)
|
|
150
|
+
return;
|
|
151
|
+
eventQueue.current = [];
|
|
152
|
+
update((s) => {
|
|
153
|
+
let next = s;
|
|
154
|
+
for (const ev of events)
|
|
155
|
+
next = applyEvent(next, ev);
|
|
156
|
+
return { ...next, items: [...next.items] };
|
|
157
|
+
});
|
|
158
|
+
}, [update]);
|
|
159
|
+
const queueEvent = useCallback((ev) => {
|
|
160
|
+
// T-03: permission modal must appear instantly (not batched) — the run
|
|
161
|
+
// blocks on the answer.
|
|
162
|
+
if (ev.type === "permission_request") {
|
|
163
|
+
setPermPrompt(ev);
|
|
146
164
|
return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
165
|
+
}
|
|
166
|
+
eventQueue.current.push(ev);
|
|
167
|
+
if (!flushTimer.current)
|
|
168
|
+
flushTimer.current = setTimeout(flushEvents, FLUSH_MS);
|
|
169
|
+
}, [flushEvents]);
|
|
170
|
+
// Flush on unmount so a trailing burst is never dropped.
|
|
171
|
+
useEffect(() => () => {
|
|
172
|
+
if (flushTimer.current)
|
|
173
|
+
clearTimeout(flushTimer.current);
|
|
174
|
+
flushEvents();
|
|
175
|
+
}, [flushEvents]);
|
|
176
|
+
const pushNotice = useCallback((text, level = "info") => update((s) => ({ ...s, items: [...s.items, { kind: "notice", text, level }] })), [update]);
|
|
177
|
+
/** Session picker: when a session is selected, resume it. */
|
|
178
|
+
const handleSessionSelect = useCallback(async (session) => {
|
|
179
|
+
setSessionPickerOpen(false);
|
|
180
|
+
if (runningRef.current) {
|
|
181
|
+
pushNotice("agent is running — wait or Esc first", "warn");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const s = resolveSessionId(config.workdir, session.id);
|
|
185
|
+
if (!s) {
|
|
186
|
+
pushNotice(`session not found: ${session.id}`, "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Restore full TUI state from the session (items + step count + model info).
|
|
190
|
+
update(() => ({
|
|
191
|
+
...initialState(),
|
|
192
|
+
items: s.messages
|
|
193
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
194
|
+
.map((m) => m.role === "user"
|
|
195
|
+
? { kind: "user", text: typeof m.content === "string" ? m.content : "" }
|
|
196
|
+
: { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false }),
|
|
197
|
+
step: s.steps ?? 0,
|
|
198
|
+
maxSteps: s.maxSteps ?? config.maxSteps,
|
|
199
|
+
model: s.model || config.model,
|
|
200
|
+
provider: s.provider || config.provider,
|
|
201
|
+
status: "idle",
|
|
202
|
+
summary: null,
|
|
203
|
+
}));
|
|
204
|
+
runningRef.current = true;
|
|
205
|
+
setScrollOffset(0);
|
|
206
|
+
const agent = createAgent();
|
|
207
|
+
try {
|
|
208
|
+
await agent.resume(s);
|
|
209
|
+
runningRef.current = false;
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
runningRef.current = false;
|
|
213
|
+
pushNotice(`resume failed: ${err.message}`, "error");
|
|
214
|
+
}
|
|
215
|
+
}, [config, createAgent, pushNotice, update]);
|
|
216
|
+
/** T1: CommandHost cho registry dùng chung với REPL. */
|
|
217
|
+
const tuiHost = useCallback(() => ({
|
|
218
|
+
agent: () => agentRef.current,
|
|
219
|
+
config,
|
|
220
|
+
isRunning: () => runningRef.current,
|
|
221
|
+
replaceAgent: (next) => {
|
|
222
|
+
agentRef.current?.abort();
|
|
223
|
+
agentRef.current?.dispose();
|
|
224
|
+
agentRef.current = next;
|
|
225
|
+
if (!next) {
|
|
226
|
+
runningRef.current = false;
|
|
227
|
+
setScrollOffset(0);
|
|
228
|
+
eventQueue.current = [];
|
|
229
|
+
update(() => initialState());
|
|
230
|
+
}
|
|
231
|
+
setPermMode(config.permissionMode);
|
|
232
|
+
},
|
|
233
|
+
resumeAndRun: async (s) => {
|
|
234
|
+
const agent = createAgent();
|
|
235
|
+
// Replace the pane with the restored transcript, then continue.
|
|
236
|
+
update(() => ({
|
|
237
|
+
...initialState(),
|
|
238
|
+
items: s.messages
|
|
239
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
240
|
+
.map((m) => (m.role === "user" ? { kind: "user", text: typeof m.content === "string" ? m.content : "" } : { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false })),
|
|
241
|
+
}));
|
|
242
|
+
runningRef.current = true;
|
|
243
|
+
setScrollOffset(0);
|
|
244
|
+
try {
|
|
245
|
+
await agent.resume(s);
|
|
246
|
+
runningRef.current = false;
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
runningRef.current = false;
|
|
250
|
+
pushNotice(`resume failed: ${err.message}`, "error");
|
|
251
|
+
}
|
|
252
|
+
return { success: true, steps: 0, buildCmd: config.buildCmd, summary: "resumed" };
|
|
253
|
+
},
|
|
254
|
+
}), [config, update, pushNotice, createAgent]);
|
|
255
|
+
/** One round of the /review state machine: call the gate, render the result. */
|
|
256
|
+
const reviewStep = useCallback(async (tool, args) => {
|
|
257
|
+
const agent = agentRef.current ?? createAgent();
|
|
258
|
+
reviewBusyRef.current = true;
|
|
259
|
+
try {
|
|
260
|
+
const r = await agent.callMcpTool(tool, args);
|
|
261
|
+
const out = reviewFromResponse(reviewRef.current, tool, args, r);
|
|
262
|
+
reviewRef.current = out.state;
|
|
263
|
+
if (out.notice)
|
|
264
|
+
pushNotice(out.notice.text, out.notice.level);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
reviewRef.current = null;
|
|
268
|
+
pushNotice(`prompt gate failed: ${err.message}`, "error");
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
reviewBusyRef.current = false;
|
|
272
|
+
}
|
|
273
|
+
}, [createAgent, pushNotice]);
|
|
274
|
+
/** Handle a user reply while /review is waiting (numbers 1-4 / feedback text). */
|
|
275
|
+
const reviewAnswer = useCallback((text) => {
|
|
276
|
+
const rv = reviewRef.current;
|
|
277
|
+
if (!rv || reviewBusyRef.current)
|
|
278
|
+
return false;
|
|
279
|
+
const out = reviewAnswerToCall(rv, text);
|
|
280
|
+
if (out.notice)
|
|
281
|
+
pushNotice(out.notice.text, out.notice.level);
|
|
282
|
+
if (out.nextCall)
|
|
283
|
+
void reviewStep(out.nextCall.tool, out.nextCall.args);
|
|
284
|
+
return true;
|
|
285
|
+
}, [reviewStep, pushNotice]);
|
|
286
|
+
// Close any lingering streaming messages when the user sends a new query —
|
|
287
|
+
// prevents green/dim text from the previous response appearing stale.
|
|
288
|
+
const closeStreamingItems = useCallback(() => {
|
|
289
|
+
update((s) => {
|
|
290
|
+
let changed = false;
|
|
291
|
+
for (const it of s.items) {
|
|
292
|
+
if (it.kind === "assistant" && it.streaming) {
|
|
293
|
+
it.streaming = false;
|
|
294
|
+
changed = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return changed ? { ...s, items: [...s.items] } : s;
|
|
298
|
+
});
|
|
299
|
+
}, [update]);
|
|
300
|
+
/**
|
|
301
|
+
* Shared entry point for every task submission:
|
|
302
|
+
* 1. Close any still-streaming assistant message from the previous response.
|
|
303
|
+
* 2. Record the query in history.
|
|
304
|
+
* 3. Clear the input field.
|
|
305
|
+
* Then the caller handles steer vs. new-run separately.
|
|
306
|
+
*/
|
|
307
|
+
const preSubmit = useCallback((task) => {
|
|
308
|
+
closeStreamingItems();
|
|
309
|
+
historyRef.current = [...historyRef.current, task].slice(-100);
|
|
310
|
+
writeInput("");
|
|
311
|
+
}, [closeStreamingItems, writeInput]);
|
|
152
312
|
const submit = useCallback(async (text) => {
|
|
153
313
|
const task = text.trim();
|
|
154
314
|
if (!task)
|
|
155
315
|
return;
|
|
316
|
+
// Prompt-gate review in progress — numeric replies drive the flow.
|
|
317
|
+
if (reviewRef.current) {
|
|
318
|
+
writeInput("");
|
|
319
|
+
historyRef.current = [...historyRef.current, task].slice(-100);
|
|
320
|
+
reviewAnswer(task);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
156
323
|
// "exit"/"quit" always works — aborts a running agent first.
|
|
157
324
|
if (task === "exit" || task === "quit") {
|
|
158
325
|
agentRef.current?.abort();
|
|
159
326
|
exit();
|
|
160
327
|
return;
|
|
161
328
|
}
|
|
162
|
-
// Slash commands —
|
|
329
|
+
// Slash commands — TUI-specific first (/review /diff /theme /undo),
|
|
330
|
+
// then the shared registry (T1) for the rest.
|
|
163
331
|
const slash = parseSlashCommand(task);
|
|
164
332
|
if (slash) {
|
|
165
333
|
writeInput("");
|
|
166
|
-
if (slash.cmd === "/
|
|
167
|
-
|
|
168
|
-
|
|
334
|
+
if (slash.cmd === "/review") {
|
|
335
|
+
if (!slash.arg) {
|
|
336
|
+
pushNotice("usage: /review <prompt> — rewrites it, you pick original/rewritten/update/cancel", "warn");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!Object.keys(config.mcpServers ?? {}).some((s) => s === "prompt-gate")) {
|
|
340
|
+
pushNotice("no \"prompt-gate\" MCP server configured — add it to ~/.scoder/config.json", "error");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Context grounded in this session: last user item + current workdir.
|
|
344
|
+
const lastUser = [...stateRef.current.items].reverse().find((i) => i.kind === "user");
|
|
345
|
+
const context = `User is in scoder TUI at ${config.workdir}${lastUser ? `; earlier they asked: "${lastUser.text.slice(0, 120)}"` : ""}.`;
|
|
346
|
+
void reviewStep("mcp__prompt-gate__review_and_select_prompt", {
|
|
347
|
+
prompt: slash.arg,
|
|
348
|
+
context,
|
|
349
|
+
language: "vietnamese",
|
|
350
|
+
});
|
|
169
351
|
return;
|
|
170
352
|
}
|
|
171
|
-
if (slash.cmd === "/
|
|
172
|
-
|
|
353
|
+
if (slash.cmd === "/diff") {
|
|
354
|
+
// T-06: /diff turn | session | branch (default: turn). TUI-specific —
|
|
355
|
+
// turn scope reads the reducer's diff items.
|
|
356
|
+
const scope = slash.arg.trim() || "turn";
|
|
357
|
+
const diffs = stateRef.current.items.filter((i) => i.kind === "diff");
|
|
358
|
+
if (scope === "turn") {
|
|
359
|
+
// Diffs since the last user message (current turn).
|
|
360
|
+
let count = 0;
|
|
361
|
+
const turnDiffs = [];
|
|
362
|
+
for (let i = stateRef.current.items.length - 1; i >= 0; i--) {
|
|
363
|
+
const it = stateRef.current.items[i];
|
|
364
|
+
if (it.kind === "user" && count > 0)
|
|
365
|
+
break;
|
|
366
|
+
if (it.kind === "diff") {
|
|
367
|
+
count++;
|
|
368
|
+
turnDiffs.unshift(`✎ ${it.path} (+${it.added}/-${it.removed})\n${it.text}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
pushNotice(turnDiffs.length ? turnDiffs.join("\n\n") : "no file edits in this turn yet");
|
|
372
|
+
}
|
|
373
|
+
else if (scope === "session") {
|
|
374
|
+
const total = diffs.reduce((a, d) => ({ add: a.add + d.added, rem: a.rem + d.removed }), { add: 0, rem: 0 });
|
|
375
|
+
pushNotice(diffs.length === 0
|
|
376
|
+
? "no file edits this session"
|
|
377
|
+
: diffs.map((d) => `✎ ${d.path} (+${d.added}/-${d.removed})`).join("\n") + `\n${diffs.length} files · +${total.add}/-${total.rem}`);
|
|
378
|
+
}
|
|
379
|
+
else if (scope === "branch") {
|
|
380
|
+
const agent = agentRef.current;
|
|
381
|
+
const snaps = agent?.snapshots.list() ?? [];
|
|
382
|
+
if (!agent || snaps.length === 0) {
|
|
383
|
+
pushNotice("no snapshot checkpoints — /diff branch needs at least one captured edit batch");
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
const d = agent.snapshots.diff(snaps[0].tree, null);
|
|
387
|
+
pushNotice(d ? d : "worktree matches the first checkpoint (no net change)");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
pushNotice("usage: /diff [turn|session|branch]", "warn");
|
|
392
|
+
}
|
|
173
393
|
return;
|
|
174
394
|
}
|
|
175
|
-
if (slash.cmd === "/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
395
|
+
if (slash.cmd === "/theme") {
|
|
396
|
+
// T-09: palette hot-swap.
|
|
397
|
+
if (!slash.arg) {
|
|
398
|
+
pushNotice(`themes: ${Object.keys(THEMES).join(", ")} — /theme <name>; project default qua .scoder/theme.json`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
pushNotice(useNamedTheme(slash.arg.trim()), "info");
|
|
402
|
+
bumpFrame();
|
|
182
403
|
return;
|
|
183
404
|
}
|
|
184
|
-
if (slash.cmd === "/
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
405
|
+
if (slash.cmd === "/undo") {
|
|
406
|
+
// T-07: /undo [edit|turn|files]
|
|
407
|
+
// (bare)|files — revert files to the previous checkpoint.
|
|
408
|
+
// edit — files + trim conversation back one turn, then the user
|
|
409
|
+
// can edit and re-send (fork/edit-resend, opencode UX).
|
|
410
|
+
const agent = agentRef.current;
|
|
411
|
+
const mode = slash.arg.trim() || "files";
|
|
412
|
+
const cps = agent?.snapshots.list() ?? [];
|
|
413
|
+
if (!agent || cps.length < 2) {
|
|
414
|
+
pushNotice("undo: no snapshot checkpoint to revert to", "warn");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (mode !== "files" && mode !== "edit") {
|
|
418
|
+
pushNotice("usage: /undo [files|edit]", "warn");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Revert to the second-newest checkpoint (newest = current state).
|
|
422
|
+
const target = cps[cps.length - 2];
|
|
423
|
+
const ok = agent.snapshots.restore(target.tree);
|
|
424
|
+
if (!ok) {
|
|
425
|
+
pushNotice("undo: restore failed", "warn");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (mode === "edit") {
|
|
429
|
+
// Fork: trim the transcript UI back to the last user message and
|
|
430
|
+
// put it in the input for editing + re-send.
|
|
431
|
+
const items = stateRef.current.items;
|
|
432
|
+
let lastUserIdx = -1;
|
|
433
|
+
for (let i = items.length - 1; i >= 0; i--)
|
|
434
|
+
if (items[i].kind === "user") {
|
|
435
|
+
lastUserIdx = i;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
const resend = lastUserIdx >= 0 && items[lastUserIdx].kind === "user" ? items[lastUserIdx].text : "";
|
|
439
|
+
update((s) => ({ ...s, items: lastUserIdx >= 0 ? items.slice(0, lastUserIdx) : s.items, status: "idle", summary: null }));
|
|
440
|
+
if (resend)
|
|
441
|
+
writeInput(resend);
|
|
442
|
+
agent.trimLastTurn();
|
|
443
|
+
pushNotice(`undo edit: files → ${target.id}; last task back in the input — edit + re-send`, "info");
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
pushNotice(`undo: reverted to ${target.id} (${target.label})`);
|
|
447
|
+
}
|
|
190
448
|
return;
|
|
191
449
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
450
|
+
// T1: phần còn lại qua registry dùng chung với REPL.
|
|
451
|
+
const { handled, outcome } = await dispatchCommand(task, tuiHost(), pushNotice);
|
|
452
|
+
if (handled && outcome?.quit) {
|
|
453
|
+
agentRef.current?.abort();
|
|
454
|
+
exit();
|
|
196
455
|
}
|
|
197
|
-
pushNotice(`unknown command ${slash.cmd} — try /help`, "warn");
|
|
198
456
|
return;
|
|
199
457
|
}
|
|
200
|
-
if (runningRef.current)
|
|
201
|
-
|
|
202
|
-
|
|
458
|
+
if (runningRef.current) {
|
|
459
|
+
// Steer: user input while the agent runs — deliver at next step
|
|
460
|
+
// boundary (H-08, "steer by default" — pattern from opencode).
|
|
461
|
+
preSubmit(task);
|
|
462
|
+
agentRef.current?.steer(task);
|
|
463
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "user", text: `${task} (steered)` }] }));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
preSubmit(task);
|
|
203
467
|
runningRef.current = true;
|
|
204
468
|
setScrollOffset(0);
|
|
205
469
|
// isFirst must be computed BEFORE pushing the user item below.
|
|
206
470
|
const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
|
|
207
471
|
// runTurn() doesn't emit run_start, so the TUI shows the user's message
|
|
208
472
|
// itself; applyEvent's run_start dedupe keeps turn 1 from doubling.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (!agent) {
|
|
212
|
-
agent = new Agent(config);
|
|
213
|
-
// Functional updates: React feeds each updater the latest queued
|
|
214
|
-
// state, so events can never land on a stale snapshot (a ref mirror
|
|
215
|
-
// updated in useEffect races events arriving within one render batch
|
|
216
|
-
// — tool results then miss their items and stay "running…" forever).
|
|
217
|
-
agent.onEvent = (ev) => setState((prev) => {
|
|
218
|
-
const next = applyEvent(prev, ev);
|
|
219
|
-
return { ...next, items: [...next.items] };
|
|
220
|
-
});
|
|
221
|
-
agentRef.current = agent;
|
|
222
|
-
}
|
|
473
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
|
|
474
|
+
const agent = createAgent();
|
|
223
475
|
try {
|
|
224
476
|
// First submission runs the full closed loop; later ones continue the session.
|
|
225
477
|
const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
|
|
@@ -228,33 +480,76 @@ export function TuiApp({ config, initialTask, bootNotice }) {
|
|
|
228
480
|
}
|
|
229
481
|
catch (err) {
|
|
230
482
|
runningRef.current = false;
|
|
231
|
-
|
|
483
|
+
// Surface the failure through the reducer (not just a notice) so the
|
|
484
|
+
// status leaves "running" and the error is visible as the final line.
|
|
485
|
+
update((s) => applyEvent({ ...s, items: [...s.items] }, { type: "finish", success: false, steps: s.step, summary: `Run failed: ${err.message}` }));
|
|
486
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: err.message, level: "error" }] }));
|
|
232
487
|
}
|
|
233
|
-
}, [config, pushNotice, writeInput, exit]);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
488
|
+
}, [config, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, reviewAnswer, reviewStep, tuiHost, preSubmit]);
|
|
489
|
+
/** Create (or return) the session agent — permission mode always current. */
|
|
490
|
+
function createAgent() {
|
|
491
|
+
if (agentRef.current)
|
|
492
|
+
return agentRef.current;
|
|
493
|
+
const agent = new Agent({ ...config, permissionMode: permMode });
|
|
494
|
+
// Events go through the batching queue (see FLUSH_MS above): applyEvent
|
|
495
|
+
// still receives the latest state because update() keeps stateRef in
|
|
496
|
+
// sync synchronously — tool results can never miss their items.
|
|
497
|
+
agent.onEvent = queueEvent;
|
|
498
|
+
agentRef.current = agent;
|
|
499
|
+
return agent;
|
|
500
|
+
}
|
|
240
501
|
// Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
|
|
241
502
|
// the first Esc returns to the bottom instead.
|
|
242
503
|
const scrollOffsetRef = useRef(0);
|
|
243
504
|
useEffect(() => {
|
|
244
505
|
scrollOffsetRef.current = scrollOffset;
|
|
245
506
|
}, [scrollOffset]);
|
|
507
|
+
// T-03: permission modal keybinds — intercepts y/a/n/q while open.
|
|
508
|
+
useInput((input, key) => {
|
|
509
|
+
const p = permPromptRef.current;
|
|
510
|
+
if (!p)
|
|
511
|
+
return;
|
|
512
|
+
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;
|
|
513
|
+
if (!answer)
|
|
514
|
+
return;
|
|
515
|
+
setPermPrompt(null);
|
|
516
|
+
agentRef.current?.resolvePermission(p.id, answer);
|
|
517
|
+
});
|
|
518
|
+
// T-01/T-02: scroll + abort keybinds.
|
|
246
519
|
useInput((_input, key) => {
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
520
|
+
if (permPromptRef.current)
|
|
521
|
+
return; // modal owns the keyboard
|
|
522
|
+
// ── Ctrl+S: open session picker (when not running, not already open) ──
|
|
523
|
+
if (key.ctrl && _input === "s") {
|
|
524
|
+
if (!runningRef.current && !sessionPickerOpen) {
|
|
525
|
+
setSessionPickerOpen(true);
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// T-10: ctrl+t toggles the thinking stream visibility.
|
|
530
|
+
if (key.ctrl && _input === "t") {
|
|
531
|
+
update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (key.pageUp || key.pageDown || key.home || key.end) {
|
|
535
|
+
const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
|
|
536
|
+
if (key.pageUp)
|
|
537
|
+
setScrollOffset((o) => o + page);
|
|
538
|
+
if (key.pageDown)
|
|
539
|
+
setScrollOffset((o) => Math.max(0, o - page));
|
|
540
|
+
if (key.home)
|
|
541
|
+
setScrollOffset(999_999); // top (clamped to maxScroll in render)
|
|
542
|
+
if (key.end)
|
|
543
|
+
setScrollOffset(0); // bottom = follow
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
251
546
|
if (key.escape) {
|
|
252
547
|
if (scrollOffsetRef.current > 0) {
|
|
253
548
|
setScrollOffset(0);
|
|
254
549
|
return;
|
|
255
550
|
}
|
|
256
551
|
if (runningRef.current) {
|
|
257
|
-
|
|
552
|
+
update((s) => ({ ...markAborted(s), items: [...s.items] }));
|
|
258
553
|
agentRef.current?.abort();
|
|
259
554
|
}
|
|
260
555
|
}
|
|
@@ -266,7 +561,7 @@ export function TuiApp({ config, initialTask, bootNotice }) {
|
|
|
266
561
|
return;
|
|
267
562
|
if (runningRef.current && ctrlCArmed.current === null) {
|
|
268
563
|
ctrlCArmed.current = Date.now();
|
|
269
|
-
|
|
564
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
|
|
270
565
|
setTimeout(() => (ctrlCArmed.current = null), 2000);
|
|
271
566
|
return;
|
|
272
567
|
}
|
|
@@ -286,28 +581,40 @@ export function TuiApp({ config, initialTask, bootNotice }) {
|
|
|
286
581
|
return null;
|
|
287
582
|
return state.summary;
|
|
288
583
|
}, [state.status, state.summary]);
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
584
|
+
// When the run ends, jump back to the bottom so the final summary is always
|
|
585
|
+
// visible — a user scrolled up would otherwise never see the conclusion.
|
|
586
|
+
const prevStatusRef = useRef(state.status);
|
|
587
|
+
useEffect(() => {
|
|
588
|
+
if (prevStatusRef.current === "running" && state.status !== "running")
|
|
589
|
+
setScrollOffset(0);
|
|
590
|
+
prevStatusRef.current = state.status;
|
|
591
|
+
}, [state.status]);
|
|
592
|
+
// The viewport renders items at full height so scroll math (content.height) is
|
|
593
|
+
// always correct. Ink clips with overflow="hidden"; contentOffsetY shifts the
|
|
594
|
+
// content column upward to reveal items above the current bottom.
|
|
595
|
+
const { rows, columns } = useWindowSize();
|
|
596
|
+
const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
|
|
597
|
+
return (
|
|
598
|
+
// flexShrink={0}: pinned chrome must never be squeezed when the chat
|
|
599
|
+
// content overflows — Ink defaults every box to flexShrink:1, which
|
|
600
|
+
// shrank the input bar to zero rows once the log outgrew the terminal.
|
|
601
|
+
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: config }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: scrollTop, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" }), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { dimColor: true, children: ["\u23CE send \u00B7 ", _jsx(Text, { bold: true, children: "ctrl+s" }), " sessions \u00B7 tab complete \u00B7 /help \u00B7 /skills"] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: "green", bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: "red", bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: config })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: config.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: config, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
|
|
298
602
|
}
|
|
299
603
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
300
604
|
export async function runTui(config, initialTask) {
|
|
301
605
|
setQuiet(true);
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
let bootNotice = null;
|
|
305
|
-
if (config.permissionMode === "ask") {
|
|
306
|
-
config = { ...config, permissionMode: "auto" };
|
|
307
|
-
bootNotice = "permission=ask is not supported in the TUI — falling back to auto";
|
|
308
|
-
}
|
|
606
|
+
// T-03: permission=ask works in the TUI — prompts route through the
|
|
607
|
+
// in-pane modal (permission_request events), not stdin readline.
|
|
309
608
|
const { render } = await import("ink");
|
|
310
|
-
|
|
609
|
+
// Alternate screen buffer (vim/htop style): the app owns the whole screen,
|
|
610
|
+
// the BuildRail stays pinned at the top and the input at the bottom while
|
|
611
|
+
// the chat pane scrolls internally. Terminal content is restored on exit.
|
|
612
|
+
// Fallback: terminals without alt-screen support (TERM=dumb, non-TTY pipes,
|
|
613
|
+
// some CI runners) render inline instead — the layout still works.
|
|
614
|
+
const altScreen = process.stdout.isTTY === true && process.env.TERM !== "dumb";
|
|
615
|
+
const { waitUntilExit } = render(_jsx(TuiApp, { config: config, initialTask: initialTask }), {
|
|
616
|
+
alternateScreen: altScreen,
|
|
617
|
+
});
|
|
311
618
|
await waitUntilExit();
|
|
312
619
|
// Ink restores the terminal on unmount; exit explicitly so provider
|
|
313
620
|
// keep-alive sockets (undici pool) can't keep the event loop alive.
|