openshain 0.3.1 → 0.4.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/NOTICE +4 -0
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/work.js +7 -1
- package/dist/labels.js +2 -0
- package/dist/preview.d.ts +13 -0
- package/dist/preview.js +141 -0
- package/dist/report.js +1 -1
- package/dist/tui/app.js +38 -5
- package/dist/tui/banner.d.ts +3 -8
- package/dist/tui/banner.js +2 -2
- package/dist/tui/controller.d.ts +22 -0
- package/dist/tui/controller.js +209 -6
- package/dist/tui/lines.d.ts +5 -3
- package/dist/tui/lines.js +31 -3
- package/dist/tui/markdown.d.ts +15 -0
- package/dist/tui/markdown.js +199 -0
- package/package.json +9 -7
- package/src/commands/init.ts +3 -2
- package/src/commands/work.ts +7 -0
- package/src/labels.ts +2 -0
- package/src/preview.ts +157 -0
- package/src/report.ts +1 -1
- package/src/tui/app.tsx +75 -13
- package/src/tui/banner.ts +4 -10
- package/src/tui/controller.ts +228 -5
- package/src/tui/lines.ts +36 -6
- package/src/tui/markdown.ts +214 -0
package/NOTICE
ADDED
package/dist/commands/init.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare const CONFIG_TEMPLATE: string;
|
|
|
6
6
|
/** Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH. */
|
|
7
7
|
export declare const MCP_TEMPLATE: string;
|
|
8
8
|
/** What an outside agent reads before working in the folder. Codex reads AGENTS.md; Claude Code reads it through CLAUDE.md. */
|
|
9
|
-
export declare const AGENTS_TEMPLATE = "# \u3053\u306E\u4F1A\u793E\u30D5\u30A9\u30EB\u30C0\u3067\u50CD\u304F\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u3078\n\n\u3053\u306E\u30D5\u30A9\u30EB\u30C0\u306F openshain \u306E Company Workspace \u3067\u3059\u3002\u3053\u306E\u6307\u793A\u306F\u3001Claude Code \u3084 Codex \u306E\u3088\u3046\u306A\u5916\u90E8\u306E\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u304C MCP \u7D4C\u7531\u3067\u3053\u306E\u30D5\u30A9\u30EB\u30C0\u3092\u6271\u3046\u3068\u304D\u306E\u3082\u306E\u3067\u3059\u3002openshain \u306E\u5BFE\u8A71\u578B CLI \u3082\u540C\u3058\u624B\u9806\u3067 Runtime \u3092\u4F7F\u3044\u307E\u3059\u3002\n\n\u4F1A\u793E\u306E\u30D5\u30A1\u30A4\u30EB\u306E\u8AAD\u307F\u66F8\u304D\u3068\u96C6\u8A08\u306F openshain \u306E MCP tool \u3067\u884C\u3044\u307E\u3059\u3002Claude Code \u3084 Codex \u81EA\u8EAB\u306E Read\u3001Write\u3001Bash \u306F\u4F1A\u793E\u306E\u30D5\u30A1\u30A4\u30EB\u306B\u306F\u4F7F\u3044\u307E\u305B\u3093\u3002Runtime \u3092\u901A\u3089\u306A\u304B\u3063\u305F\u64CD\u4F5C\u306F\u8A18\u9332\u306B\u6B8B\u3089\u306A\u3044\u305F\u3081\u3067\u3059\u3002\n\n- \u4F9D\u983C\u3092\u53D7\u3051\u305F\u3089\u3001\u307E\u305A `work_create` \u306B\u4F9D\u983C\u306E\u6587\u3092\u305D\u306E\u307E\u307E\u6E21\u3057\u3066 Work \u3092\u4F5C\u308B\n- \u30D5\u30A1\u30A4\u30EB\u306F `fs_list`\u3001`fs_search`\u3001`fs_read`\u3001`csv_read`\u3001`markdown_read` \u3067\u898B\u308B\u3002\u5408\u8A08\u3084\u4EF6\u6570\u306F `csv_aggregate` \u306B\u4EFB\u305B\u3001\u81EA\u5206\u3067\u5408\u8A08\u3057\u306A\u3044\n- \u66F8\u304F\u3068\u304D\u306F `fs_write` \u304B `csv_write`\n- \u7D42\u308F\u3063\u305F\u3089 `work_complete` \u306B\u3001\u4F55\u3092\u3057\u305F\u304B\u3068\u3001\u66F8\u3044\u305F\u30D5\u30A1\u30A4\u30EB\u3092\u6E21\u3059\u3002\u7D9A\u3051\u3089\u308C\u306A\u3044\u3068\u304D\u306F `work_fail`\n- `openshain.yaml`
|
|
9
|
+
export declare const AGENTS_TEMPLATE = "# \u3053\u306E\u4F1A\u793E\u30D5\u30A9\u30EB\u30C0\u3067\u50CD\u304F\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u3078\n\n\u3053\u306E\u30D5\u30A9\u30EB\u30C0\u306F openshain \u306E Company Workspace \u3067\u3059\u3002\u3053\u306E\u6307\u793A\u306F\u3001Claude Code \u3084 Codex \u306E\u3088\u3046\u306A\u5916\u90E8\u306E\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u304C MCP \u7D4C\u7531\u3067\u3053\u306E\u30D5\u30A9\u30EB\u30C0\u3092\u6271\u3046\u3068\u304D\u306E\u3082\u306E\u3067\u3059\u3002openshain \u306E\u5BFE\u8A71\u578B CLI \u3082\u540C\u3058\u624B\u9806\u3067 Runtime \u3092\u4F7F\u3044\u307E\u3059\u3002\n\n\u4F1A\u793E\u306E\u30D5\u30A1\u30A4\u30EB\u306E\u8AAD\u307F\u66F8\u304D\u3068\u96C6\u8A08\u306F openshain \u306E MCP tool \u3067\u884C\u3044\u307E\u3059\u3002Claude Code \u3084 Codex \u81EA\u8EAB\u306E Read\u3001Write\u3001Bash \u306F\u4F1A\u793E\u306E\u30D5\u30A1\u30A4\u30EB\u306B\u306F\u4F7F\u3044\u307E\u305B\u3093\u3002Runtime \u3092\u901A\u3089\u306A\u304B\u3063\u305F\u64CD\u4F5C\u306F\u8A18\u9332\u306B\u6B8B\u3089\u306A\u3044\u305F\u3081\u3067\u3059\u3002\n\n- \u4F9D\u983C\u3092\u53D7\u3051\u305F\u3089\u3001\u307E\u305A `work_create` \u306B\u4F9D\u983C\u306E\u6587\u3092\u305D\u306E\u307E\u307E\u6E21\u3057\u3066 Work \u3092\u4F5C\u308B\n- \u30D5\u30A1\u30A4\u30EB\u306F `fs_list`\u3001`fs_search`\u3001`fs_read`\u3001`csv_read`\u3001`markdown_read` \u3067\u898B\u308B\u3002\u5408\u8A08\u3084\u4EF6\u6570\u306F `csv_aggregate` \u306B\u4EFB\u305B\u3001\u81EA\u5206\u3067\u5408\u8A08\u3057\u306A\u3044\n- \u66F8\u304F\u3068\u304D\u306F `fs_write` \u304B `csv_write`\n- \u7D42\u308F\u3063\u305F\u3089 `work_complete` \u306B\u3001\u4F55\u3092\u3057\u305F\u304B\u3068\u3001\u66F8\u3044\u305F\u30D5\u30A1\u30A4\u30EB\u3092\u6E21\u3059\u3002\u7D9A\u3051\u3089\u308C\u306A\u3044\u3068\u304D\u306F `work_fail`\n- `openshain.yaml`\u3001`work/`\u3001`principals/`\u3001`authority/` \u306F Runtime \u306E\u3082\u306E\u3002\u5909\u66F4\u3057\u306A\u3044\n- \u547C\u3073\u51FA\u3057\u306E\u7D50\u679C\u304C `pending` \u306A\u3089\u3001\u4F1A\u793E\u306E\u6A29\u9650\u306E\u898F\u5247\u304C\u305D\u306E\u547C\u3073\u51FA\u3057\u3092\u6B62\u3081\u3066\u3044\u308B\u3002\u627F\u8A8D\u306F\u4F1A\u793E\u306E\u4EBA\u304C openshain \u306E\u753B\u9762\u3067\u6C7A\u3081\u308B\u3002\u81EA\u5206\u3067 `approval_decide` \u3084 `review_decide` \u3092\u547C\u3093\u3067\u901A\u3055\u306A\u3044\u3002\u4EBA\u306B\u4F1D\u3048\u3066\u3001\u6C7A\u307E\u308B\u307E\u3067\u5225\u306E\u4F5C\u696D\u3092\u3059\u308B\n";
|
|
10
10
|
export declare const CLAUDE_TEMPLATE = "@AGENTS.md\n";
|
|
11
11
|
export interface InitOptions {
|
|
12
12
|
workspaceRoot: string;
|
package/dist/commands/init.js
CHANGED
|
@@ -20,7 +20,7 @@ profession:
|
|
|
20
20
|
id: generic
|
|
21
21
|
# model への指示。100,000 文字まで
|
|
22
22
|
instructions: |
|
|
23
|
-
|
|
23
|
+
あなたはこの会社の一般事務の社員エージェントです。依頼された作業を、workspace 内のファイルだけを使って進めてください。
|
|
24
24
|
model:
|
|
25
25
|
provider: anthropic # anthropic | openai-compatible
|
|
26
26
|
model: claude-opus-5
|
|
@@ -52,7 +52,8 @@ export const AGENTS_TEMPLATE = `# この会社フォルダで働くエージェ
|
|
|
52
52
|
- ファイルは \`fs_list\`、\`fs_search\`、\`fs_read\`、\`csv_read\`、\`markdown_read\` で見る。合計や件数は \`csv_aggregate\` に任せ、自分で合計しない
|
|
53
53
|
- 書くときは \`fs_write\` か \`csv_write\`
|
|
54
54
|
- 終わったら \`work_complete\` に、何をしたかと、書いたファイルを渡す。続けられないときは \`work_fail\`
|
|
55
|
-
- \`openshain.yaml
|
|
55
|
+
- \`openshain.yaml\`、\`work/\`、\`principals/\`、\`authority/\` は Runtime のもの。変更しない
|
|
56
|
+
- 呼び出しの結果が \`pending\` なら、会社の権限の規則がその呼び出しを止めている。承認は会社の人が openshain の画面で決める。自分で \`approval_decide\` や \`review_decide\` を呼んで通さない。人に伝えて、決まるまで別の作業をする
|
|
56
57
|
`;
|
|
57
58
|
export const CLAUDE_TEMPLATE = "@AGENTS.md\n";
|
|
58
59
|
/**
|
package/dist/commands/work.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseWorkId, pendingQuestions, WorkStore, } from "@openshain/core";
|
|
1
|
+
import { parseWorkId, pendingApprovals, pendingQuestions, WorkStore, } from "@openshain/core";
|
|
2
2
|
import { describeInput, padDisplay } from "../format.js";
|
|
3
3
|
import { errorLabel, failureLabel, rejectionLabel, statusLabel } from "../labels.js";
|
|
4
4
|
import { nextActor } from "../report.js";
|
|
@@ -51,6 +51,12 @@ export function describeWork(work, events) {
|
|
|
51
51
|
for (const { question } of pendingQuestions(events))
|
|
52
52
|
lines.push(`質問 ${question}`);
|
|
53
53
|
}
|
|
54
|
+
if (work.status === "waiting_approval") {
|
|
55
|
+
for (const a of pendingApprovals(events)) {
|
|
56
|
+
const who = a.kind === "review" ? `${a.reviewer?.role ?? "資格者"}の判断待ち` : "承認待ち";
|
|
57
|
+
lines.push(`${who} ${a.call.name} ${describeInput(a.call.input)} (${a.approvalId})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
54
60
|
const calls = toolLines(events);
|
|
55
61
|
if (calls.length > 0) {
|
|
56
62
|
lines.push("Tool");
|
package/dist/labels.js
CHANGED
|
@@ -24,6 +24,8 @@ export const REJECTION_LABELS = {
|
|
|
24
24
|
outside_workspace: "workspace の外",
|
|
25
25
|
invalid_path: "不正なパス",
|
|
26
26
|
limit_reached: "Tool 呼び出しの上限",
|
|
27
|
+
denied: "権限の表で不許可",
|
|
28
|
+
rejected_by_person: "承認されなかった",
|
|
27
29
|
};
|
|
28
30
|
/** A heading for a runtime error, before the original message. */
|
|
29
31
|
export const ERROR_LABELS = {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface PreviewLine {
|
|
2
|
+
kind: "added" | "removed" | "context" | "note";
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* What a held call would change, for the person about to approve it. A call of a tool that writes
|
|
7
|
+
* a whole file is shown as a line diff against the file as it is now; anything else is shown as
|
|
8
|
+
* its input. Reads the file directly: this is the person's own workspace, on their own screen.
|
|
9
|
+
*/
|
|
10
|
+
export declare function previewCall(workspaceRoot: string, call: {
|
|
11
|
+
name: string;
|
|
12
|
+
input: unknown;
|
|
13
|
+
}): Promise<PreviewLine[]>;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolveWorkspacePath } from "@openshain/core";
|
|
3
|
+
import { csvText } from "@openshain/tools";
|
|
4
|
+
/** How much of a change the screen shows before it says the rest is cut. */
|
|
5
|
+
const MAX_LINES = 24;
|
|
6
|
+
/** Above this many lines on either side, the diff is replaced by the line counts. */
|
|
7
|
+
const MAX_DIFF_LINES = 400;
|
|
8
|
+
/** A single line longer than this is cut: one line must not fill the screen. */
|
|
9
|
+
const MAX_LINE_CHARS = 300;
|
|
10
|
+
/** The tools whose input the screen can render as the file it would leave behind. */
|
|
11
|
+
const DIFFABLE = new Set(["fs_write", "csv_write"]);
|
|
12
|
+
/**
|
|
13
|
+
* What a held call would change, for the person about to approve it. A call of a tool that writes
|
|
14
|
+
* a whole file is shown as a line diff against the file as it is now; anything else is shown as
|
|
15
|
+
* its input. Reads the file directly: this is the person's own workspace, on their own screen.
|
|
16
|
+
*/
|
|
17
|
+
export async function previewCall(workspaceRoot, call) {
|
|
18
|
+
if (!DIFFABLE.has(call.name))
|
|
19
|
+
return [{ kind: "note", text: JSON.stringify(call.input) }];
|
|
20
|
+
const input = (call.input ?? {});
|
|
21
|
+
const path = typeof input.path === "string" ? input.path : undefined;
|
|
22
|
+
const content = typeof input.content === "string"
|
|
23
|
+
? input.content
|
|
24
|
+
: Array.isArray(input.rows)
|
|
25
|
+
? csvText(input.rows, Array.isArray(input.columns) ? input.columns : undefined)
|
|
26
|
+
: undefined;
|
|
27
|
+
if (path === undefined || content === undefined) {
|
|
28
|
+
return [{ kind: "note", text: JSON.stringify(call.input) }];
|
|
29
|
+
}
|
|
30
|
+
// The same guard the tools run under: a path outside the workspace, a reserved one or a
|
|
31
|
+
// symlink that leads out is refused here too, so the screen never shows what the call cannot
|
|
32
|
+
// touch. The model chooses this path; the person is about to read what it says.
|
|
33
|
+
let resolved;
|
|
34
|
+
try {
|
|
35
|
+
resolved = await resolveWorkspacePath(workspaceRoot, path);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
kind: "note",
|
|
41
|
+
text: `${path} は読めません(${err instanceof Error ? err.message : String(err)})。この呼び出しは実行しても拒否されます。`,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
const before = await readFile(resolved, "utf8").catch(() => undefined);
|
|
46
|
+
if (before === undefined) {
|
|
47
|
+
const lines = content.split("\n");
|
|
48
|
+
return cap([
|
|
49
|
+
{ kind: "note", text: `${path} を新しく作ります(${lines.length} 行)` },
|
|
50
|
+
...lines.map((text) => ({ kind: "added", text })),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
const oldLines = before.split("\n");
|
|
54
|
+
const newLines = content.split("\n");
|
|
55
|
+
if (oldLines.length > MAX_DIFF_LINES || newLines.length > MAX_DIFF_LINES) {
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
kind: "note",
|
|
59
|
+
text: `${path} を書き換えます(${oldLines.length} 行 → ${newLines.length} 行。大きいので差分は出しません)`,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
const body = diff(oldLines, newLines);
|
|
64
|
+
return cap([{ kind: "note", text: `${path} を書き換えます` }, ...body]);
|
|
65
|
+
}
|
|
66
|
+
function cap(lines) {
|
|
67
|
+
const short = lines.map((line) => line.text.length > MAX_LINE_CHARS
|
|
68
|
+
? { ...line, text: `${line.text.slice(0, MAX_LINE_CHARS)}…` }
|
|
69
|
+
: line);
|
|
70
|
+
if (short.length <= MAX_LINES)
|
|
71
|
+
return short;
|
|
72
|
+
const rest = short.length - MAX_LINES;
|
|
73
|
+
return [...short.slice(0, MAX_LINES), { kind: "note", text: `ほか ${rest} 行` }];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A line diff by the longest common subsequence, with the unchanged lines around a change kept
|
|
77
|
+
* as context. Small files only; the caller checks the size first.
|
|
78
|
+
*/
|
|
79
|
+
function diff(before, after) {
|
|
80
|
+
const table = Array.from({ length: before.length + 1 }, () => new Array(after.length + 1).fill(0));
|
|
81
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
82
|
+
for (let j = after.length - 1; j >= 0; j--) {
|
|
83
|
+
const row = table[i];
|
|
84
|
+
const next = table[i + 1];
|
|
85
|
+
row[j] =
|
|
86
|
+
before[i] === after[j]
|
|
87
|
+
? next[j + 1] + 1
|
|
88
|
+
: Math.max(next[j], row[j + 1]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const all = [];
|
|
92
|
+
let i = 0;
|
|
93
|
+
let j = 0;
|
|
94
|
+
while (i < before.length && j < after.length) {
|
|
95
|
+
if (before[i] === after[j]) {
|
|
96
|
+
all.push({ kind: "context", text: before[i] });
|
|
97
|
+
i++;
|
|
98
|
+
j++;
|
|
99
|
+
}
|
|
100
|
+
else if ((table[i + 1]?.[j] ?? 0) >= (table[i]?.[j + 1] ?? 0)) {
|
|
101
|
+
all.push({ kind: "removed", text: before[i] });
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
all.push({ kind: "added", text: after[j] });
|
|
106
|
+
j++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (; i < before.length; i++)
|
|
110
|
+
all.push({ kind: "removed", text: before[i] });
|
|
111
|
+
for (; j < after.length; j++)
|
|
112
|
+
all.push({ kind: "added", text: after[j] });
|
|
113
|
+
return trimContext(all);
|
|
114
|
+
}
|
|
115
|
+
/** Keeps two unchanged lines on each side of a change and marks what was left out. */
|
|
116
|
+
function trimContext(lines, keep = 2) {
|
|
117
|
+
const wanted = new Set();
|
|
118
|
+
lines.forEach((line, index) => {
|
|
119
|
+
if (line.kind === "context")
|
|
120
|
+
return;
|
|
121
|
+
for (let k = index - keep; k <= index + keep; k++)
|
|
122
|
+
wanted.add(k);
|
|
123
|
+
});
|
|
124
|
+
const out = [];
|
|
125
|
+
let skipped = 0;
|
|
126
|
+
lines.forEach((line, index) => {
|
|
127
|
+
if (wanted.has(index)) {
|
|
128
|
+
if (skipped > 0) {
|
|
129
|
+
out.push({ kind: "note", text: `… 変わらない ${skipped} 行 …` });
|
|
130
|
+
skipped = 0;
|
|
131
|
+
}
|
|
132
|
+
out.push(line);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
skipped++;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (skipped > 0)
|
|
139
|
+
out.push({ kind: "note", text: `… 変わらない ${skipped} 行 …` });
|
|
140
|
+
return out;
|
|
141
|
+
}
|
package/dist/report.js
CHANGED
|
@@ -64,7 +64,7 @@ export function nextActor(work) {
|
|
|
64
64
|
case "waiting_input":
|
|
65
65
|
return `次は利用者の番です。openshain の会話で /work resume ${work.id} を実行し、続きを依頼すると質問に答えられます。`;
|
|
66
66
|
case "waiting_approval":
|
|
67
|
-
return "次は利用者の番です。承認が要ります。";
|
|
67
|
+
return "次は利用者の番です。承認が要ります。openshain の会話で /approvals を確かめ、/approve <id> か /reject <id> で決めます。";
|
|
68
68
|
case "failed":
|
|
69
69
|
return "次は利用者の番です。原因を修正して、もう一度依頼してください。";
|
|
70
70
|
default:
|
package/dist/tui/app.js
CHANGED
|
@@ -15,7 +15,10 @@ const COLORS = {
|
|
|
15
15
|
};
|
|
16
16
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
17
17
|
/** Rows that are not history: the header, the input box (three rows) and the status line. */
|
|
18
|
+
/** The rows around the conversation: the header, the input box (3) and the status line. */
|
|
18
19
|
const CHROME_ROWS = 5;
|
|
20
|
+
/** The approval palette is taller: its two lines of heading plus one row per choice. */
|
|
21
|
+
const APPROVAL_EXTRA_ROWS = 2;
|
|
19
22
|
function statusText(state, scrolled) {
|
|
20
23
|
if (scrolled > 0)
|
|
21
24
|
return `↑ ${scrolled} 行上を表示中。End で最新へ、ホイールか PageUp と PageDown で移動`;
|
|
@@ -64,9 +67,12 @@ export function App({ controller }) {
|
|
|
64
67
|
return () => clearInterval(timer);
|
|
65
68
|
}, [state.busy]);
|
|
66
69
|
// One row is left to the terminal: drawing exactly its height makes it scroll on every redraw.
|
|
67
|
-
const
|
|
70
|
+
const chromeRows = (state.approval
|
|
71
|
+
? CHROME_ROWS + APPROVAL_EXTRA_ROWS + state.approval.choices.length
|
|
72
|
+
: CHROME_ROWS) + (state.queued.length > 0 && !state.approval ? 1 : 0);
|
|
73
|
+
const height = Math.max(chromeRows + 1, size.rows - 1);
|
|
68
74
|
const width = Math.max(20, size.columns);
|
|
69
|
-
const paneRows = height -
|
|
75
|
+
const paneRows = height - chromeRows;
|
|
70
76
|
const lines = useMemo(() => screenLines(state.entries, width).map((line, row) => ({ ...line, row })), [state.entries, width]);
|
|
71
77
|
const maxScroll = Math.max(0, lines.length - paneRows);
|
|
72
78
|
const scrolled = Math.min(scroll, maxScroll);
|
|
@@ -93,6 +99,22 @@ export function App({ controller }) {
|
|
|
93
99
|
void controller.close();
|
|
94
100
|
return;
|
|
95
101
|
}
|
|
102
|
+
// While a call waits for approval, the keys pick a choice instead of typing.
|
|
103
|
+
if (state.approval) {
|
|
104
|
+
if (key.upArrow)
|
|
105
|
+
return controller.moveApproval(-1);
|
|
106
|
+
if (key.downArrow)
|
|
107
|
+
return controller.moveApproval(1);
|
|
108
|
+
if (key.return)
|
|
109
|
+
return controller.decideApproval();
|
|
110
|
+
if (key.escape)
|
|
111
|
+
return controller.decideApproval("reject");
|
|
112
|
+
const index = Number.parseInt(ch, 10) - 1;
|
|
113
|
+
const chosen = state.approval.choices[index];
|
|
114
|
+
if (chosen)
|
|
115
|
+
return controller.decideApproval(chosen.key);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
96
118
|
if (key.pageUp)
|
|
97
119
|
return setScroll((s) => Math.min(maxScroll, s + page));
|
|
98
120
|
if (key.pageDown)
|
|
@@ -167,7 +189,9 @@ export function App({ controller }) {
|
|
|
167
189
|
setInput([...chars.slice(0, at), ch, ...chars.slice(at)].join(""));
|
|
168
190
|
setCursor(at + [...ch].length);
|
|
169
191
|
});
|
|
192
|
+
const approval = state.approval;
|
|
170
193
|
const asking = state.question !== undefined;
|
|
194
|
+
const queued = state.queued;
|
|
171
195
|
const chars = [...input];
|
|
172
196
|
const at = Math.min(cursor, chars.length);
|
|
173
197
|
const before = chars.slice(0, at).join("");
|
|
@@ -183,9 +207,18 @@ export function App({ controller }) {
|
|
|
183
207
|
state.status.model,
|
|
184
208
|
].join(" · ") }), _jsx(Box, { flexDirection: "column", height: paneRows, children: visible.map((line) => {
|
|
185
209
|
const color = COLORS[line.kind];
|
|
186
|
-
if (line.
|
|
187
|
-
|
|
210
|
+
if (line.spans) {
|
|
211
|
+
// Each piece is named by the column it starts at, which does not move.
|
|
212
|
+
let column = 0;
|
|
213
|
+
const pieces = line.spans.map((span) => {
|
|
214
|
+
const at = column;
|
|
215
|
+
column += span.text.length;
|
|
216
|
+
return { ...span, at };
|
|
217
|
+
});
|
|
218
|
+
return (_jsx(Text, { wrap: "truncate", ...(color && { color }), children: pieces.map((s) => (_jsx(Text, { ...(s.color && { color: s.color }), ...(s.bold && { bold: true }), ...(s.italic && { italic: true }), ...(s.dim && { dimColor: true }), ...(s.strikethrough && { strikethrough: true }), children: s.text }, `${line.row}-${s.at}`))) }, line.row));
|
|
188
219
|
}
|
|
189
220
|
return (_jsx(Text, { wrap: "truncate", dimColor: line.kind === "banner", ...(color && { color }), children: line.text || " " }, line.row));
|
|
190
|
-
}) }), _jsxs(Box, { borderStyle: "round", borderColor: asking ? "magenta" : "gray", paddingX: 1, children: [_jsx(Text, { color: asking ? "magenta" : "cyan", children: asking ? "答え> " : "> " }), _jsx(Text, { children: before }), under === "" ? _jsx(Text, { dimColor: true, children: "\u258C" }) : _jsx(Text, { inverse: true, children: under }), _jsx(Text, { children: after })] }),
|
|
221
|
+
}) }), approval ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", wrap: "truncate", children: ["\u627F\u8A8D\u304C\u8981\u308A\u307E\u3059: ", approval.title] }), _jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u898F\u5247 ", approval.ruleId] }), approval.choices.map((choice, index) => (_jsxs(Text, { ...(index === approval.at && { color: "yellow" }), wrap: "truncate", children: [index === approval.at ? "❯ " : " ", index + 1, ". ", choice.label] }, choice.key)))] })) : (_jsxs(Box, { borderStyle: "round", borderColor: asking ? "magenta" : "gray", paddingX: 1, children: [_jsx(Text, { color: asking ? "magenta" : "cyan", children: asking ? "答え> " : "> " }), _jsx(Text, { children: before }), under === "" ? _jsx(Text, { dimColor: true, children: "\u258C" }) : _jsx(Text, { inverse: true, children: under }), _jsx(Text, { children: after })] })), queued.length > 0 && !approval ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u9806\u756A\u5F85\u3061 ", queued.length, " \u4EF6: ", queued.join(" / ")] })) : null, _jsx(Text, { dimColor: true, wrap: "truncate", children: approval
|
|
222
|
+
? "↑ ↓ と Enter、または数字で選ぶ。Esc は拒否します。Ctrl-C は保留のまま止めます"
|
|
223
|
+
: bottom })] }));
|
|
191
224
|
}
|
package/dist/tui/banner.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Span } from "./markdown.ts";
|
|
1
2
|
/** The version of the openshain command, from its package.json. */
|
|
2
3
|
export declare const VERSION: string;
|
|
3
4
|
/**
|
|
@@ -5,11 +6,5 @@ export declare const VERSION: string;
|
|
|
5
6
|
* screen needs neither the network nor another dependency to show it.
|
|
6
7
|
*/
|
|
7
8
|
export declare const LOGO_ROWS: readonly string[];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
color: string;
|
|
11
|
-
/** Position in the row; the screen keys by it. */
|
|
12
|
-
at: number;
|
|
13
|
-
}
|
|
14
|
-
/** One colored segment per character, so the gradient runs across the row. */
|
|
15
|
-
export declare function logoSegments(row: string): Segment[];
|
|
9
|
+
/** One colored piece per character, so the gradient runs across the row. */
|
|
10
|
+
export declare function logoSegments(row: string): Span[];
|
package/dist/tui/banner.js
CHANGED
|
@@ -15,7 +15,7 @@ const GRADIENT = [
|
|
|
15
15
|
[78, 168, 255],
|
|
16
16
|
[127, 136, 255],
|
|
17
17
|
];
|
|
18
|
-
/** One colored
|
|
18
|
+
/** One colored piece per character, so the gradient runs across the row. */
|
|
19
19
|
export function logoSegments(row) {
|
|
20
20
|
const chars = [...row];
|
|
21
21
|
const last = Math.max(1, chars.length - 1);
|
|
@@ -24,6 +24,6 @@ export function logoSegments(row) {
|
|
|
24
24
|
const [from, to] = GRADIENT;
|
|
25
25
|
const channel = (k) => Math.round(from[k] + (to[k] - from[k]) * t);
|
|
26
26
|
const hex = [channel(0), channel(1), channel(2)].map((v) => v.toString(16).padStart(2, "0"));
|
|
27
|
-
return { text, color: `#${hex.join("")}
|
|
27
|
+
return { text, color: `#${hex.join("")}` };
|
|
28
28
|
});
|
|
29
29
|
}
|
package/dist/tui/controller.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { type ApprovalChoice } from "@openshain/agent";
|
|
1
2
|
import { type RuntimeProviders, type WorkId, WorkStore } from "@openshain/core";
|
|
2
3
|
import { statusLabel } from "../labels.ts";
|
|
4
|
+
import { type PreviewLine } from "../preview.ts";
|
|
3
5
|
/** logo and banner are the rows shown once when the screen opens: the wordmark, the version, the folder. */
|
|
4
6
|
export type EntryKind = "user" | "assistant" | "progress" | "notice" | "question" | "line" | "logo" | "banner";
|
|
5
7
|
export interface Entry {
|
|
@@ -16,6 +18,22 @@ export interface ControllerState {
|
|
|
16
18
|
busy: boolean;
|
|
17
19
|
/** A question a work is asking; the next line the person types answers it. */
|
|
18
20
|
question?: string;
|
|
21
|
+
/** A call held for approval; the person picks one of its choices before the work goes on. */
|
|
22
|
+
approval?: {
|
|
23
|
+
approvalId: string;
|
|
24
|
+
title: string;
|
|
25
|
+
ruleId: string;
|
|
26
|
+
preview: PreviewLine[];
|
|
27
|
+
choices: {
|
|
28
|
+
key: ApprovalChoice | "reject_with_reason";
|
|
29
|
+
label: string;
|
|
30
|
+
}[];
|
|
31
|
+
at: number;
|
|
32
|
+
};
|
|
33
|
+
/** After "no, and tell the agent why": the next line the person types is that reason. */
|
|
34
|
+
reason?: string;
|
|
35
|
+
/** Lines typed while the screen was busy. They are sent in order once it is free. */
|
|
36
|
+
queued: string[];
|
|
19
37
|
closed: boolean;
|
|
20
38
|
status: {
|
|
21
39
|
company: string;
|
|
@@ -41,6 +59,10 @@ export interface Controller {
|
|
|
41
59
|
submit(line: string): Promise<void>;
|
|
42
60
|
/** Ctrl-C: stops the running work, taking back a question it waits on; false when nothing was running. */
|
|
43
61
|
interrupt(): boolean;
|
|
62
|
+
/** Moves the highlight in the approval choices. */
|
|
63
|
+
moveApproval(delta: number): void;
|
|
64
|
+
/** Answers the approval being shown: the highlighted choice, or the one given. */
|
|
65
|
+
decideApproval(choice?: ApprovalChoice | "reject_with_reason"): void;
|
|
44
66
|
/** Stops whatever is running, then ends the session. A second call waits for the same close. */
|
|
45
67
|
close(): Promise<void>;
|
|
46
68
|
}
|