openshain 0.1.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.
@@ -0,0 +1,223 @@
1
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import type { Controller, ControllerState } from "./controller.ts";
4
+ import { type ScreenLine, screenLines } from "./lines.ts";
5
+
6
+ const COLORS: Record<ScreenLine["kind"], string | undefined> = {
7
+ user: "cyan",
8
+ assistant: undefined,
9
+ progress: "gray",
10
+ notice: "yellow",
11
+ question: "magenta",
12
+ line: undefined,
13
+ logo: undefined,
14
+ banner: undefined,
15
+ blank: undefined,
16
+ };
17
+
18
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
19
+
20
+ /** Rows that are not history: the header, the input box (three rows) and the status line. */
21
+ const CHROME_ROWS = 5;
22
+
23
+ function statusText(state: ControllerState, scrolled: number): string {
24
+ if (scrolled > 0)
25
+ return `↑ ${scrolled} 行上を表示中。End で最新へ、ホイールか PageUp と PageDown で移動`;
26
+ const { work, usage } = state.status;
27
+ const parts = [];
28
+ if (work) parts.push(`${work.id} ${work.status}`);
29
+ parts.push(
30
+ `model ${usage.modelCalls} 回、入力 ${usage.inputTokens}、出力 ${usage.outputTokens} トークン`,
31
+ );
32
+ parts.push("/help で使い方");
33
+ return parts.join(" · ");
34
+ }
35
+
36
+ /**
37
+ * The whole terminal: a header, the conversation with the newest rows at the bottom, the input
38
+ * box, and a status line. The conversation scrolls inside the screen with the mouse wheel, PageUp
39
+ * and PageDown. The up and down arrows recall the lines sent before; the input is edited in place.
40
+ */
41
+ export function App({ controller }: { controller: Controller }) {
42
+ const { exit } = useApp();
43
+ const { stdout } = useStdout();
44
+ const [state, setState] = useState<ControllerState>(() => ({ ...controller.state() }));
45
+ const [input, setInput] = useState("");
46
+ /** Where the next character goes, counted in characters, not bytes. */
47
+ const [cursor, setCursor] = useState(0);
48
+ const [history, setHistory] = useState<string[]>([]);
49
+ /** While the arrows walk the history: where we are, and what the input held before. */
50
+ const [recall, setRecall] = useState<{ index: number; draft: string } | undefined>();
51
+ const [scroll, setScroll] = useState(0);
52
+ const [size, setSize] = useState({ columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
53
+ const [tick, setTick] = useState(0);
54
+
55
+ useEffect(() => controller.subscribe(() => setState({ ...controller.state() })), [controller]);
56
+ useEffect(() => {
57
+ if (state.closed) exit();
58
+ }, [state.closed, exit]);
59
+ useEffect(() => {
60
+ const onResize = () => setSize({ columns: stdout.columns ?? 80, rows: stdout.rows ?? 24 });
61
+ stdout.on("resize", onResize);
62
+ return () => {
63
+ stdout.off("resize", onResize);
64
+ };
65
+ }, [stdout]);
66
+ useEffect(() => {
67
+ if (!state.busy) return;
68
+ const timer = setInterval(() => setTick((t) => t + 1), 100);
69
+ return () => clearInterval(timer);
70
+ }, [state.busy]);
71
+
72
+ // One row is left to the terminal: drawing exactly its height makes it scroll on every redraw.
73
+ const height = Math.max(CHROME_ROWS + 1, size.rows - 1);
74
+ const width = Math.max(20, size.columns);
75
+ const paneRows = height - CHROME_ROWS;
76
+ const lines = useMemo(
77
+ () => screenLines(state.entries, width).map((line, row) => ({ ...line, row })),
78
+ [state.entries, width],
79
+ );
80
+ const maxScroll = Math.max(0, lines.length - paneRows);
81
+ const scrolled = Math.min(scroll, maxScroll);
82
+ const end = lines.length - scrolled;
83
+ const visible = lines.slice(Math.max(0, end - paneRows), end);
84
+ const page = Math.max(1, paneRows - 1);
85
+
86
+ /** Replaces the input and puts the cursor after it. */
87
+ const replaceInput = (text: string) => {
88
+ setInput(text);
89
+ setCursor([...text].length);
90
+ };
91
+
92
+ useInput((ch, key) => {
93
+ // The terminal reports the mouse (SGR). The wheel scrolls the conversation; the rest is ignored.
94
+ if (/\[<\d+;\d+;\d+[Mm]/.test(ch)) {
95
+ let delta = 0;
96
+ for (const m of ch.matchAll(/\[<(6[45]);\d+;\d+M/g)) delta += m[1] === "64" ? 3 : -3;
97
+ if (delta !== 0) setScroll((s) => Math.max(0, Math.min(maxScroll, s + delta)));
98
+ return;
99
+ }
100
+ if (key.ctrl && ch === "c") {
101
+ if (!controller.interrupt()) void controller.close();
102
+ return;
103
+ }
104
+ if (key.pageUp) return setScroll((s) => Math.min(maxScroll, s + page));
105
+ if (key.pageDown) return setScroll((s) => Math.max(0, s - page));
106
+ // Up and down walk the lines sent before; below the newest one is what was being typed.
107
+ if (key.upArrow) {
108
+ const index = (recall?.index ?? history.length) - 1;
109
+ if (index < 0) return;
110
+ setRecall({ index, draft: recall?.draft ?? input });
111
+ replaceInput(history[index] ?? "");
112
+ return;
113
+ }
114
+ if (key.downArrow) {
115
+ if (!recall) return;
116
+ const index = recall.index + 1;
117
+ if (index >= history.length) {
118
+ replaceInput(recall.draft);
119
+ setRecall(undefined);
120
+ return;
121
+ }
122
+ setRecall({ ...recall, index });
123
+ replaceInput(history[index] ?? "");
124
+ return;
125
+ }
126
+ // Editing: the cursor moves with the arrows, Home and End (also Ctrl-A and Ctrl-E); Backspace
127
+ // deletes before it, Delete at it, Ctrl-U everything before it, Ctrl-K everything after it.
128
+ const chars = [...input];
129
+ const at = Math.min(cursor, chars.length);
130
+ if (key.leftArrow) return setCursor(Math.max(0, at - 1));
131
+ if (key.rightArrow) return setCursor(Math.min(chars.length, at + 1));
132
+ if (key.home || (key.ctrl && ch === "a")) return setCursor(0);
133
+ if (key.end || (key.ctrl && ch === "e")) return setCursor(chars.length);
134
+ if (key.backspace) {
135
+ if (at === 0) return;
136
+ setInput([...chars.slice(0, at - 1), ...chars.slice(at)].join(""));
137
+ setCursor(at - 1);
138
+ return;
139
+ }
140
+ if (key.delete) {
141
+ setInput([...chars.slice(0, at), ...chars.slice(at + 1)].join(""));
142
+ return;
143
+ }
144
+ if (key.ctrl && ch === "u") {
145
+ setInput(chars.slice(at).join(""));
146
+ setCursor(0);
147
+ return;
148
+ }
149
+ if (key.ctrl && ch === "k") return setInput(chars.slice(0, at).join(""));
150
+ // Ink hands a pasted or quickly typed chunk over whole, and a newline inside it does not set
151
+ // key.return. The line ends at the first newline; what follows becomes the next input.
152
+ const newline = key.return ? 0 : ch.search(/[\r\n]/);
153
+ if (newline >= 0) {
154
+ const line = [...chars.slice(0, at), ch.slice(0, newline), ...chars.slice(at)].join("");
155
+ if (line.trim() !== "") setHistory((h) => (h.at(-1) === line ? h : [...h, line]));
156
+ setRecall(undefined);
157
+ replaceInput(ch.slice(newline + 1).replace(/^\n/, ""));
158
+ setScroll(0);
159
+ void controller.submit(line);
160
+ return;
161
+ }
162
+ if (key.tab || key.escape || key.ctrl || key.meta) return;
163
+ setInput([...chars.slice(0, at), ch, ...chars.slice(at)].join(""));
164
+ setCursor(at + [...ch].length);
165
+ });
166
+
167
+ const asking = state.question !== undefined;
168
+ const chars = [...input];
169
+ const at = Math.min(cursor, chars.length);
170
+ const before = chars.slice(0, at).join("");
171
+ const under = chars[at] ?? "";
172
+ const after = chars.slice(at + 1).join("");
173
+ const bottom = state.busy
174
+ ? `${SPINNER[tick % SPINNER.length]} 作業中。Ctrl-C で止める`
175
+ : statusText(state, scrolled);
176
+ return (
177
+ <Box flexDirection="column" width={width} height={height}>
178
+ <Text dimColor wrap="truncate">
179
+ {[
180
+ "openshain",
181
+ state.status.company,
182
+ ...(state.status.agentName ? [`社員エージェント ${state.status.agentName}`] : []),
183
+ state.status.model,
184
+ ].join(" · ")}
185
+ </Text>
186
+ <Box flexDirection="column" height={paneRows}>
187
+ {visible.map((line) => {
188
+ const color = COLORS[line.kind];
189
+ if (line.segments) {
190
+ return (
191
+ <Text key={line.row} wrap="truncate">
192
+ {line.segments.map((s) => (
193
+ <Text key={s.at} color={s.color}>
194
+ {s.text}
195
+ </Text>
196
+ ))}
197
+ </Text>
198
+ );
199
+ }
200
+ return (
201
+ <Text
202
+ key={line.row}
203
+ wrap="truncate"
204
+ dimColor={line.kind === "banner"}
205
+ {...(color && { color })}
206
+ >
207
+ {line.text || " "}
208
+ </Text>
209
+ );
210
+ })}
211
+ </Box>
212
+ <Box borderStyle="round" borderColor={asking ? "magenta" : "gray"} paddingX={1}>
213
+ <Text color={asking ? "magenta" : "cyan"}>{asking ? "答え> " : "> "}</Text>
214
+ <Text>{before}</Text>
215
+ {under === "" ? <Text dimColor>▌</Text> : <Text inverse>{under}</Text>}
216
+ <Text>{after}</Text>
217
+ </Box>
218
+ <Text dimColor wrap="truncate">
219
+ {bottom}
220
+ </Text>
221
+ </Box>
222
+ );
223
+ }
@@ -0,0 +1,40 @@
1
+ import cli from "../../package.json" with { type: "json" };
2
+
3
+ /** The version of the openshain command, from its package.json. */
4
+ export const VERSION: string = cli.version;
5
+
6
+ /**
7
+ * The wordmark as `oh-my-logo "openshain" --filled --block-font chrome` draws it, kept here so the
8
+ * screen needs neither the network nor another dependency to show it.
9
+ */
10
+ export const LOGO_ROWS: readonly string[] = Object.freeze([
11
+ " ╔═╗ ╔═╗ ╔═╗ ╔╗╔ ╔═╗ ╦ ╦ ╔═╗ ╦ ╔╗╔",
12
+ " ║ ║ ╠═╝ ║╣ ║║║ ╚═╗ ╠═╣ ╠═╣ ║ ║║║",
13
+ " ╚═╝ ╩ ╚═╝ ╝╚╝ ╚═╝ ╩ ╩ ╩ ╩ ╩ ╝╚╝",
14
+ ]);
15
+
16
+ /** oh-my-logo's grad-blue palette, from the left edge of the wordmark to the right. */
17
+ const GRADIENT: readonly [readonly [number, number, number], readonly [number, number, number]] = [
18
+ [78, 168, 255],
19
+ [127, 136, 255],
20
+ ];
21
+
22
+ export interface Segment {
23
+ text: string;
24
+ color: string;
25
+ /** Position in the row; the screen keys by it. */
26
+ at: number;
27
+ }
28
+
29
+ /** One colored segment per character, so the gradient runs across the row. */
30
+ export function logoSegments(row: string): Segment[] {
31
+ const chars = [...row];
32
+ const last = Math.max(1, chars.length - 1);
33
+ return chars.map((text, at) => {
34
+ const t = at / last;
35
+ const [from, to] = GRADIENT;
36
+ const channel = (k: 0 | 1 | 2) => Math.round(from[k] + (to[k] - from[k]) * t);
37
+ const hex = [channel(0), channel(1), channel(2)].map((v) => v.toString(16).padStart(2, "0"));
38
+ return { text, color: `#${hex.join("")}`, at };
39
+ });
40
+ }
@@ -0,0 +1,357 @@
1
+ import { createSession, type Session, type TurnResult } from "@openshain/agent";
2
+ import {
3
+ type AnyEvent,
4
+ createRuntime,
5
+ type Event,
6
+ type Runtime,
7
+ type RuntimeProviders,
8
+ type WorkId,
9
+ } from "@openshain/core";
10
+ import { progressLine, report } from "../commands/run.ts";
11
+ import { toolsList } from "../commands/tools.ts";
12
+ import { workList, workResume, workShow } from "../commands/work.ts";
13
+ import { plain } from "../format.ts";
14
+ import { statusLabel } from "../labels.ts";
15
+ import { LOGO_ROWS, VERSION } from "./banner.ts";
16
+
17
+ /** logo and banner are the rows shown once when the screen opens: the wordmark, the version, the folder. */
18
+ export type EntryKind =
19
+ | "user"
20
+ | "assistant"
21
+ | "progress"
22
+ | "notice"
23
+ | "question"
24
+ | "line"
25
+ | "logo"
26
+ | "banner";
27
+
28
+ export interface Entry {
29
+ id: number;
30
+ kind: EntryKind;
31
+ text: string;
32
+ }
33
+
34
+ export interface ControllerState {
35
+ /**
36
+ * Everything shown so far, in order. An entry never changes once added, and the array is
37
+ * replaced rather than mutated: the screen tells new entries apart by the array's identity.
38
+ */
39
+ entries: Entry[];
40
+ busy: boolean;
41
+ /** A question a work is asking; the next line the person types answers it. */
42
+ question?: string;
43
+ closed: boolean;
44
+ status: {
45
+ company: string;
46
+ model: string;
47
+ /** The name the agent goes by in this conversation. */
48
+ agentName?: string;
49
+ work?: { id: string; status: string };
50
+ usage: { modelCalls: number; inputTokens: number; outputTokens: number };
51
+ };
52
+ }
53
+
54
+ export interface Controller {
55
+ readonly sessionId: WorkId;
56
+ state(): ControllerState;
57
+ subscribe(listener: () => void): () => void;
58
+ /** A line the person typed: an answer, a slash command, or something to say. */
59
+ submit(line: string): Promise<void>;
60
+ /** Ctrl-C: stops the running work, taking back a question it waits on; false when nothing was running. */
61
+ interrupt(): boolean;
62
+ /** Stops whatever is running, then ends the session. A second call waits for the same close. */
63
+ close(): Promise<void>;
64
+ }
65
+
66
+ export interface ControllerOptions {
67
+ workspaceRoot: string;
68
+ providers: RuntimeProviders;
69
+ runtime?: Runtime;
70
+ }
71
+
72
+ const HELP = [
73
+ "/work list Work の一覧",
74
+ "/work show <id> Work の詳細",
75
+ "/work resume <id> 止まった Work を続ける",
76
+ "/tools 使える Tool",
77
+ "/quit 終わる",
78
+ "↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
79
+ "← → Home End 入力欄でカーソルを動かす。Backspace と Delete はカーソルの位置で消す",
80
+ "ホイール、PageUp/PageDown 会話を遡る。送ると最新に戻る",
81
+ "Ctrl-C 動いている Work を止める。質問待ちなら質問を取り下げる。何も動いていなければ終わる",
82
+ ];
83
+
84
+ /** What the session's model hears when the person stops a work that waits for their answer. */
85
+ const QUESTION_WITHDRAWN =
86
+ "the person stopped the work while it waited for their answer; the question is still pending and the work can be resumed";
87
+
88
+ /** The state behind the screen: a session, the works it starts, and the lines to show. */
89
+ export async function createController(options: ControllerOptions): Promise<Controller> {
90
+ const runtime =
91
+ options.runtime ??
92
+ (await createRuntime({ workspaceRoot: options.workspaceRoot, providers: options.providers }));
93
+ const listeners = new Set<() => void>();
94
+ let nextId = 1;
95
+ const state: ControllerState = {
96
+ entries: [],
97
+ busy: false,
98
+ closed: false,
99
+ status: {
100
+ company: runtime.config.company.name,
101
+ model: `${runtime.config.model.provider}/${runtime.config.model.model}`,
102
+ usage: { modelCalls: 0, inputTokens: 0, outputTokens: 0 },
103
+ },
104
+ };
105
+ // A listener may act on the controller and cause another notification; those run after this one.
106
+ let notifying = false;
107
+ let again = false;
108
+ const notify = () => {
109
+ if (notifying) {
110
+ again = true;
111
+ return;
112
+ }
113
+ notifying = true;
114
+ try {
115
+ do {
116
+ again = false;
117
+ for (const listener of listeners) listener();
118
+ } while (again);
119
+ } finally {
120
+ notifying = false;
121
+ }
122
+ };
123
+ const push = (kind: EntryKind, text: string) => {
124
+ state.entries = [...state.entries, { id: nextId++, kind, text: plain(text) }];
125
+ notify();
126
+ };
127
+
128
+ let pending: { resolve: (text: string) => void; reject: (reason: Error) => void } | undefined;
129
+ let aborter: AbortController | undefined;
130
+ let running: Promise<void> | undefined;
131
+ let closing: Promise<void> | undefined;
132
+ /** The child work of the current turn while it is unfinished. */
133
+ let lastWorkId: WorkId | undefined;
134
+ const names = new Map<string, string>();
135
+
136
+ const ask = (workId: WorkId, question: string): Promise<string> => {
137
+ state.question = question;
138
+ push("question", `${question}(${workId})`);
139
+ return new Promise((resolve, reject) => {
140
+ pending = { resolve, reject };
141
+ });
142
+ };
143
+ /** Answers the pending question, or takes it back when there is no answer. */
144
+ const settleQuestion = (answer?: string) => {
145
+ const waiting = pending;
146
+ pending = undefined;
147
+ if (state.question !== undefined) {
148
+ delete state.question;
149
+ notify();
150
+ }
151
+ if (!waiting) return;
152
+ if (answer === undefined) waiting.reject(new Error(QUESTION_WITHDRAWN));
153
+ else waiting.resolve(answer);
154
+ };
155
+
156
+ /** The lines the CLI prints when a work ends, shown among the progress lines. */
157
+ const closingLines = async (workId: WorkId) => {
158
+ for (const line of await workReport(runtime, workId)) push("progress", line.trimStart());
159
+ };
160
+
161
+ const onWorkEvent = (workId: WorkId, event: AnyEvent): void | Promise<void> => {
162
+ lastWorkId = workId;
163
+ if (event.type === "work.status_changed") {
164
+ state.status.work = {
165
+ id: workId,
166
+ status: (event as Event<"work.status_changed">).payload.to,
167
+ };
168
+ } else if (event.type === "work.completed" || event.type === "work.failed") {
169
+ lastWorkId = undefined;
170
+ state.status.work = {
171
+ id: workId,
172
+ status: event.type === "work.completed" ? "completed" : "failed",
173
+ };
174
+ return closingLines(workId);
175
+ }
176
+ const line = progressLine(event, names);
177
+ if (line) push("progress", line);
178
+ else notify();
179
+ };
180
+
181
+ const session: Session = await createSession(runtime, {
182
+ onEvent: (event) => {
183
+ if (event.type === "usage.recorded") {
184
+ const { payload } = event as Event<"usage.recorded">;
185
+ if (payload.kind === "model_inference") {
186
+ state.status.usage.modelCalls += 1;
187
+ state.status.usage.inputTokens += payload.usage.inputTokens;
188
+ state.status.usage.outputTokens += payload.usage.outputTokens;
189
+ notify();
190
+ }
191
+ }
192
+ },
193
+ onWorkEvent,
194
+ onInput: ask,
195
+ });
196
+ state.status.agentName = session.agentName;
197
+ for (const row of LOGO_ROWS) push("logo", row);
198
+ push("banner", `openshain ${VERSION}`);
199
+ push("banner", runtime.workspaceRoot);
200
+
201
+ const stopped = (workId: string | undefined) =>
202
+ workId
203
+ ? `止めました。${workId} は途中のまま残っています。/work resume ${workId} で続けられます。`
204
+ : "止めました。";
205
+
206
+ const explain = (result: TurnResult) => {
207
+ switch (result.stopped) {
208
+ case "turn_limit":
209
+ return "社員エージェントが 1 回の返答でできる回数を超えたので、ここで止めました。続きを頼めます。";
210
+ case "aborted":
211
+ return stopped(lastWorkId);
212
+ case "max_tokens":
213
+ return "返答が長さの上限で切れました。";
214
+ case "refusal":
215
+ return "社員エージェントが続けられないと言っています。";
216
+ case "model_error":
217
+ return `model の呼び出しに失敗しました。${result.detail ?? ""}`.trim();
218
+ default:
219
+ return undefined;
220
+ }
221
+ };
222
+
223
+ const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
224
+
225
+ /** Runs one thing the person can stop with Ctrl-C, and keeps the screen busy meanwhile. */
226
+ const stoppable = async (fn: (signal: AbortSignal) => Promise<void>) => {
227
+ const stopper = new AbortController();
228
+ aborter = stopper;
229
+ state.busy = true;
230
+ notify();
231
+ running = fn(stopper.signal);
232
+ try {
233
+ await running;
234
+ } finally {
235
+ running = undefined;
236
+ aborter = undefined;
237
+ state.busy = false;
238
+ notify();
239
+ }
240
+ };
241
+
242
+ const capture = async (fn: (write: (line: string) => void) => Promise<unknown>) => {
243
+ try {
244
+ await fn((line) => push("line", line));
245
+ } catch (err) {
246
+ push("notice", message(err));
247
+ }
248
+ };
249
+
250
+ const command = async (line: string) => {
251
+ const [name, ...args] = line.slice(1).trim().split(/\s+/);
252
+ const sub = args[0] ?? "";
253
+ const id = args[1] ?? args[0] ?? "";
254
+ if (name === "help") for (const h of HELP) push("line", h);
255
+ else if (name === "quit" || name === "exit") await close();
256
+ else if (name === "tools") await capture((write) => toolsList({ ...options, write }));
257
+ else if (name === "work" && sub === "list")
258
+ await capture((write) => workList({ workspaceRoot: options.workspaceRoot, write }));
259
+ else if (name === "work" && (sub === "show" || sub === "resume") && !args[1])
260
+ push("notice", `/work ${sub} には Work の id が要ります。/work list で確かめてください。`);
261
+ else if (name === "work" && sub === "show" && id)
262
+ await capture((write) => workShow({ workspaceRoot: options.workspaceRoot, id, write }));
263
+ else if (name === "work" && sub === "resume" && id) {
264
+ await stoppable(async (signal) => {
265
+ try {
266
+ await workResume({
267
+ workspaceRoot: options.workspaceRoot,
268
+ providers: options.providers,
269
+ id,
270
+ signal,
271
+ write: (text) => push("line", text),
272
+ ask: (q) => ask(id as WorkId, q),
273
+ });
274
+ } catch (err) {
275
+ if (!signal.aborted) push("notice", message(err));
276
+ }
277
+ if (signal.aborted) push("notice", stopped(id));
278
+ });
279
+ } else if (name === "resume") {
280
+ push(
281
+ "notice",
282
+ "セッションの再開はまだありません。止まった Work を続けるなら /work resume <id> です。",
283
+ );
284
+ } else {
285
+ push("notice", `分からないコマンドです。/help で一覧が出ます。`);
286
+ }
287
+ };
288
+
289
+ function close(): Promise<void> {
290
+ closing ??= (async () => {
291
+ aborter?.abort();
292
+ settleQuestion();
293
+ await running;
294
+ await session.close();
295
+ state.closed = true;
296
+ notify();
297
+ })();
298
+ return closing;
299
+ }
300
+
301
+ return {
302
+ sessionId: session.id,
303
+ state: () => state,
304
+ subscribe(listener) {
305
+ listeners.add(listener);
306
+ return () => listeners.delete(listener);
307
+ },
308
+ async submit(line) {
309
+ const text = line.trim();
310
+ if (text === "" || closing) return;
311
+ if (pending) {
312
+ push("user", text);
313
+ // Everything typed answers the question, except leaving: that takes the question back.
314
+ if (text === "/quit" || text === "/exit") await close();
315
+ else settleQuestion(text);
316
+ return;
317
+ }
318
+ if (state.busy) {
319
+ push("notice", "いま動いています。止めるなら Ctrl-C。");
320
+ return;
321
+ }
322
+ if (text.startsWith("/")) {
323
+ push("user", text);
324
+ await command(text);
325
+ return;
326
+ }
327
+ push("user", text);
328
+ await stoppable(async (signal) => {
329
+ try {
330
+ const result = await session.turn(text, { signal });
331
+ if (result.reply) push("assistant", result.reply);
332
+ const note = explain(result);
333
+ if (note) push("notice", note);
334
+ } catch (err) {
335
+ push("notice", message(err));
336
+ }
337
+ });
338
+ },
339
+ interrupt() {
340
+ if (!aborter) return false;
341
+ aborter.abort();
342
+ settleQuestion();
343
+ return true;
344
+ },
345
+ close,
346
+ };
347
+ }
348
+
349
+ /** Lines that close a work in the screen: the CLI's closing lines without the summary, which the clerk relays. */
350
+ export async function workReport(runtime: Runtime, workId: WorkId): Promise<string[]> {
351
+ const work = await runtime.works.get(workId);
352
+ const events = await runtime.works.events(workId);
353
+ const lines = report(work, events);
354
+ return work.status === "completed" ? ["完了。", ...lines.slice(1)] : lines;
355
+ }
356
+
357
+ export { statusLabel };
@@ -0,0 +1,51 @@
1
+ import type { RuntimeProviders } from "@openshain/core";
2
+ import { render } from "ink";
3
+ import React from "react";
4
+ import { App } from "./app.tsx";
5
+ import { createController } from "./controller.ts";
6
+
7
+ export interface TuiOptions {
8
+ workspaceRoot: string;
9
+ providers: RuntimeProviders;
10
+ }
11
+
12
+ /** The alternate screen, cleared, with the terminal reporting the mouse (SGR) so the wheel reaches the screen. */
13
+ const ENTER_SCREEN = "\x1b[?1049h\x1b[H\x1b[2J\x1b[?1000h\x1b[?1006h";
14
+ const LEAVE_SCREEN = "\x1b[?1006l\x1b[?1000l\x1b[?1049l";
15
+
16
+ /** Opens the conversation screen and returns when the person leaves it. */
17
+ export async function startTui(options: TuiOptions): Promise<number> {
18
+ const controller = await createController(options);
19
+ // Whatever ends the process, the terminal gets its screen and its mouse back: an error thrown
20
+ // while drawing, a signal, or a crash. Written once.
21
+ let left = false;
22
+ const leave = () => {
23
+ if (left) return;
24
+ left = true;
25
+ process.stdout.write(LEAVE_SCREEN);
26
+ };
27
+ process.once("exit", leave);
28
+ process.stdout.write(ENTER_SCREEN);
29
+ try {
30
+ const { waitUntilExit } = render(React.createElement(App, { controller }), {
31
+ exitOnCtrlC: false,
32
+ });
33
+ // A closed terminal or a stop signal stops what runs and still ends the session in the record.
34
+ // Registered after render: Ink's own signal handling re-raises a signal when it thinks nobody
35
+ // else listens.
36
+ const closeAndExit = () => {
37
+ controller.close().finally(() => {
38
+ leave();
39
+ process.exit(0);
40
+ });
41
+ };
42
+ for (const signal of ["SIGHUP", "SIGTERM", "SIGINT"] as const)
43
+ process.once(signal, closeAndExit);
44
+ await waitUntilExit();
45
+ await controller.close();
46
+ } finally {
47
+ leave();
48
+ }
49
+ console.log(`会話を終えました。記録は openshain work show ${controller.sessionId} で読めます。`);
50
+ return 0;
51
+ }