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/src/tui/controller.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ApprovalAnswer,
|
|
3
|
+
type ApprovalChoice,
|
|
4
|
+
connectInMemory,
|
|
5
|
+
createSession,
|
|
6
|
+
type HeldApproval,
|
|
7
|
+
type Session,
|
|
8
|
+
type TurnResult,
|
|
9
|
+
} from "@openshain/agent";
|
|
2
10
|
import {
|
|
3
11
|
type Event,
|
|
4
12
|
loadConfig,
|
|
@@ -10,8 +18,9 @@ import {
|
|
|
10
18
|
import { createMcpServer } from "@openshain/mcp";
|
|
11
19
|
import { toolsList } from "../commands/tools.ts";
|
|
12
20
|
import { workList, workShow } from "../commands/work.ts";
|
|
13
|
-
import { plain } from "../format.ts";
|
|
21
|
+
import { describeInput, plain } from "../format.ts";
|
|
14
22
|
import { statusLabel } from "../labels.ts";
|
|
23
|
+
import { type PreviewLine, previewCall } from "../preview.ts";
|
|
15
24
|
import { progressLine, report } from "../report.ts";
|
|
16
25
|
import { LOGO_ROWS, VERSION } from "./banner.ts";
|
|
17
26
|
|
|
@@ -41,6 +50,19 @@ export interface ControllerState {
|
|
|
41
50
|
busy: boolean;
|
|
42
51
|
/** A question a work is asking; the next line the person types answers it. */
|
|
43
52
|
question?: string;
|
|
53
|
+
/** A call held for approval; the person picks one of its choices before the work goes on. */
|
|
54
|
+
approval?: {
|
|
55
|
+
approvalId: string;
|
|
56
|
+
title: string;
|
|
57
|
+
ruleId: string;
|
|
58
|
+
preview: PreviewLine[];
|
|
59
|
+
choices: { key: ApprovalChoice | "reject_with_reason"; label: string }[];
|
|
60
|
+
at: number;
|
|
61
|
+
};
|
|
62
|
+
/** After "no, and tell the agent why": the next line the person types is that reason. */
|
|
63
|
+
reason?: string;
|
|
64
|
+
/** Lines typed while the screen was busy. They are sent in order once it is free. */
|
|
65
|
+
queued: string[];
|
|
44
66
|
closed: boolean;
|
|
45
67
|
status: {
|
|
46
68
|
company: string;
|
|
@@ -60,6 +82,10 @@ export interface Controller {
|
|
|
60
82
|
submit(line: string): Promise<void>;
|
|
61
83
|
/** Ctrl-C: stops the running work, taking back a question it waits on; false when nothing was running. */
|
|
62
84
|
interrupt(): boolean;
|
|
85
|
+
/** Moves the highlight in the approval choices. */
|
|
86
|
+
moveApproval(delta: number): void;
|
|
87
|
+
/** Answers the approval being shown: the highlighted choice, or the one given. */
|
|
88
|
+
decideApproval(choice?: ApprovalChoice | "reject_with_reason"): void;
|
|
63
89
|
/** Stops whatever is running, then ends the session. A second call waits for the same close. */
|
|
64
90
|
close(): Promise<void>;
|
|
65
91
|
}
|
|
@@ -73,6 +99,9 @@ const HELP = [
|
|
|
73
99
|
"/work list Work の一覧",
|
|
74
100
|
"/work show <id> Work の詳細",
|
|
75
101
|
"/work resume <id> 止まった Work を候補にする。次の依頼がそれに沿えば続ける",
|
|
102
|
+
"/approvals 承認待ちの一覧",
|
|
103
|
+
"/approve <id> 承認して実行する。/reject <id> [理由] で拒否する",
|
|
104
|
+
"/review <id> approve|reject 資格者の判断を記録する。名前と本文を順に聞く",
|
|
76
105
|
"/tools 使える Tool",
|
|
77
106
|
"/quit 終わる",
|
|
78
107
|
"↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
|
|
@@ -81,10 +110,21 @@ const HELP = [
|
|
|
81
110
|
"Ctrl-C 動いている Work を止める。質問待ちなら質問を取り下げる。何も動いていなければ終わる",
|
|
82
111
|
];
|
|
83
112
|
|
|
113
|
+
/** The choices the screen offers for a held call, in the order they are shown. */
|
|
114
|
+
const APPROVAL_CHOICES: { key: ApprovalChoice | "reject_with_reason"; label: string }[] = [
|
|
115
|
+
{ key: "approve", label: "はい。実行する" },
|
|
116
|
+
{ key: "always", label: "はい。この会話では同じ規則の呼び出しを常に承認する" },
|
|
117
|
+
{ key: "reject", label: "いいえ。実行しない" },
|
|
118
|
+
{ key: "reject_with_reason", label: "いいえ。理由を伝えて実行しない" },
|
|
119
|
+
];
|
|
120
|
+
|
|
84
121
|
/** What the session's model hears when the person stops a work that waits for their answer. */
|
|
85
122
|
const QUESTION_WITHDRAWN =
|
|
86
123
|
"the person stopped the work while it waited for their answer; the question is still pending and the work can be resumed";
|
|
87
124
|
|
|
125
|
+
/** What the loop hears when the person leaves a held call undecided. */
|
|
126
|
+
const APPROVAL_WITHDRAWN = "the person left the approval undecided";
|
|
127
|
+
|
|
88
128
|
/**
|
|
89
129
|
* The state behind the screen: a session, the works it starts, and the lines to show. The
|
|
90
130
|
* conversation reaches the runtime only as an MCP client of the workspace's own server, the way
|
|
@@ -121,6 +161,7 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
121
161
|
entries: [],
|
|
122
162
|
busy: false,
|
|
123
163
|
closed: false,
|
|
164
|
+
queued: [],
|
|
124
165
|
status: {
|
|
125
166
|
company: config.company.name,
|
|
126
167
|
model: `${config.model.provider}/${config.model.model}`,
|
|
@@ -151,11 +192,23 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
151
192
|
};
|
|
152
193
|
|
|
153
194
|
let pending: { resolve: (text: string) => void; reject: (reason: Error) => void } | undefined;
|
|
195
|
+
let deciding:
|
|
196
|
+
| { resolve: (answer: ApprovalAnswer) => void; reject: (reason: Error) => void }
|
|
197
|
+
| undefined;
|
|
154
198
|
let aborter: AbortController | undefined;
|
|
155
199
|
let running: Promise<void> | undefined;
|
|
156
200
|
let closing: Promise<void> | undefined;
|
|
157
201
|
const names = new Map<string, string>();
|
|
158
202
|
|
|
203
|
+
/** Asks the person for one line and waits for it. The next line they type is the answer. */
|
|
204
|
+
const askLine = (question: string): Promise<string> => {
|
|
205
|
+
state.question = question;
|
|
206
|
+
push("question", question);
|
|
207
|
+
notify();
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
pending = { resolve, reject };
|
|
210
|
+
});
|
|
211
|
+
};
|
|
159
212
|
const ask = (workId: WorkId, question: string): Promise<string> => {
|
|
160
213
|
state.question = question;
|
|
161
214
|
push("question", `${question}(${workId})`);
|
|
@@ -163,6 +216,51 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
163
216
|
pending = { resolve, reject };
|
|
164
217
|
});
|
|
165
218
|
};
|
|
219
|
+
/** Shows a held call and waits for the person to pick one of the choices. */
|
|
220
|
+
const askApproval = async (approval: HeldApproval): Promise<ApprovalAnswer> => {
|
|
221
|
+
const preview = await previewCall(workspaceRoot, {
|
|
222
|
+
name: approval.name,
|
|
223
|
+
input: approval.input,
|
|
224
|
+
}).catch((err) => [{ kind: "note", text: message(err) } as PreviewLine]);
|
|
225
|
+
const title = `${approval.name} ${describeInput(approval.input)}`.trimEnd();
|
|
226
|
+
state.approval = {
|
|
227
|
+
approvalId: approval.approvalId,
|
|
228
|
+
title,
|
|
229
|
+
ruleId: approval.ruleId,
|
|
230
|
+
preview,
|
|
231
|
+
choices: APPROVAL_CHOICES,
|
|
232
|
+
at: 0,
|
|
233
|
+
};
|
|
234
|
+
push("question", `承認が要ります: ${title}`);
|
|
235
|
+
for (const line of preview) {
|
|
236
|
+
push(
|
|
237
|
+
"progress",
|
|
238
|
+
`${line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "}${line.text}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return new Promise<ApprovalAnswer>((resolve, reject) => {
|
|
242
|
+
deciding = { resolve, reject };
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
/** Settles the approval being shown, or takes it back when there is no choice. */
|
|
246
|
+
const settleApproval = (choice?: ApprovalChoice, comment?: string) => {
|
|
247
|
+
const waiting = deciding;
|
|
248
|
+
deciding = undefined;
|
|
249
|
+
if (state.reason !== undefined) {
|
|
250
|
+
delete state.reason;
|
|
251
|
+
notify();
|
|
252
|
+
}
|
|
253
|
+
if (state.approval !== undefined) {
|
|
254
|
+
const decided = choice ? APPROVAL_CHOICES.find((c) => c.key === choice)?.label : undefined;
|
|
255
|
+
delete state.approval;
|
|
256
|
+
if (decided) push("line", `> ${decided}`);
|
|
257
|
+
notify();
|
|
258
|
+
}
|
|
259
|
+
if (!waiting) return;
|
|
260
|
+
if (choice === undefined) waiting.reject(new Error(APPROVAL_WITHDRAWN));
|
|
261
|
+
else waiting.resolve({ choice, ...(comment !== undefined && comment !== "" && { comment }) });
|
|
262
|
+
};
|
|
263
|
+
|
|
166
264
|
/** Answers the pending question, or takes it back when there is no answer. */
|
|
167
265
|
const settleQuestion = (answer?: string) => {
|
|
168
266
|
const waiting = pending;
|
|
@@ -182,6 +280,8 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
182
280
|
};
|
|
183
281
|
|
|
184
282
|
let sessionId: WorkId | undefined;
|
|
283
|
+
/** Summaries of the works completed in this turn, until the agent reports them itself. */
|
|
284
|
+
let unreported: string[] = [];
|
|
185
285
|
const session: Session = await createSession(client, {
|
|
186
286
|
model,
|
|
187
287
|
config,
|
|
@@ -211,6 +311,12 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
211
311
|
id: workId,
|
|
212
312
|
status: event.type === "work.completed" ? "completed" : "failed",
|
|
213
313
|
};
|
|
314
|
+
if (event.type === "work.completed") {
|
|
315
|
+
// Held, not shown: the agent is the one who tells the person what happened. It is
|
|
316
|
+
// shown only if the turn ends without the agent saying anything (see submit).
|
|
317
|
+
const { summary } = (event as Event<"work.completed">).payload;
|
|
318
|
+
if (summary.trim() !== "") unreported.push(summary.trim());
|
|
319
|
+
}
|
|
214
320
|
return closingLines(workId);
|
|
215
321
|
}
|
|
216
322
|
// The work_* calls are the loop's own bookkeeping; the closing lines already say the work ended.
|
|
@@ -229,6 +335,7 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
229
335
|
else notify();
|
|
230
336
|
},
|
|
231
337
|
onInput: ask,
|
|
338
|
+
onApproval: askApproval,
|
|
232
339
|
});
|
|
233
340
|
sessionId = session.id;
|
|
234
341
|
state.status.agentName = session.agentName;
|
|
@@ -253,6 +360,14 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
253
360
|
return "社員エージェントが続けられないと言っています。";
|
|
254
361
|
case "model_error":
|
|
255
362
|
return `model の呼び出しに失敗しました。${result.detail ?? ""}`.trim();
|
|
363
|
+
case "approval": {
|
|
364
|
+
const a = result.approval;
|
|
365
|
+
if (!a) return "承認が要ります。/approvals で確かめてください。";
|
|
366
|
+
if (a.kind === "review") {
|
|
367
|
+
return `${a.reviewer?.role ?? "資格者"}の判断が要ります: ${a.name} ${describeInput(a.input)}(${a.approvalId})。Review Package は work/${a.workId}/review/ にあります。返答が届いたら /review ${a.approvalId} approve か /review ${a.approvalId} reject で記録します。`;
|
|
368
|
+
}
|
|
369
|
+
return `承認が要ります: ${a.name} ${describeInput(a.input)}(${a.approvalId})。/approve ${a.approvalId} で実行、/reject ${a.approvalId} で拒否します。`;
|
|
370
|
+
}
|
|
256
371
|
default:
|
|
257
372
|
return undefined;
|
|
258
373
|
}
|
|
@@ -277,6 +392,16 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
277
392
|
}
|
|
278
393
|
};
|
|
279
394
|
|
|
395
|
+
/** Sends what the person typed while the agent was working, oldest first. */
|
|
396
|
+
const drainQueue = async () => {
|
|
397
|
+
while (state.queued.length > 0 && !closing) {
|
|
398
|
+
const [next, ...rest] = state.queued as [string, ...string[]];
|
|
399
|
+
state.queued = rest;
|
|
400
|
+
notify();
|
|
401
|
+
await self.submit(next);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
280
405
|
const capture = async (fn: (write: (line: string) => void) => Promise<unknown>) => {
|
|
281
406
|
try {
|
|
282
407
|
await fn((line) => push("line", line));
|
|
@@ -308,6 +433,60 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
308
433
|
} catch (err) {
|
|
309
434
|
push("notice", message(err));
|
|
310
435
|
}
|
|
436
|
+
} else if (name === "approvals") {
|
|
437
|
+
try {
|
|
438
|
+
const held = await session.approvals();
|
|
439
|
+
if (held.length === 0) push("line", "承認待ちはありません。");
|
|
440
|
+
for (const a of held) {
|
|
441
|
+
push("line", `${a.approvalId} ${a.name} ${describeInput(a.input)} (${a.workId})`);
|
|
442
|
+
}
|
|
443
|
+
} catch (err) {
|
|
444
|
+
push("notice", message(err));
|
|
445
|
+
}
|
|
446
|
+
} else if ((name === "approve" || name === "reject") && sub) {
|
|
447
|
+
try {
|
|
448
|
+
const comment = args.slice(1).join(" ");
|
|
449
|
+
const { text } = await session.decide(sub, name, comment || undefined);
|
|
450
|
+
push("line", text);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
push("notice", message(err));
|
|
453
|
+
}
|
|
454
|
+
} else if (name === "approve" || name === "reject") {
|
|
455
|
+
push("notice", `/${name} には承認の id が要ります。/approvals で確かめてください。`);
|
|
456
|
+
} else if (name === "review" && sub && (args[1] === "approve" || args[1] === "reject")) {
|
|
457
|
+
const decision = args[1];
|
|
458
|
+
try {
|
|
459
|
+
// The rule already says which role has to decide; the person only says who they are.
|
|
460
|
+
const held = (await session.approvals()).find((a) => a.approvalId === sub);
|
|
461
|
+
if (!held)
|
|
462
|
+
throw new Error(`${sub} は承認待ちにありません。/approvals で確かめてください。`);
|
|
463
|
+
if (held.kind !== "review") {
|
|
464
|
+
throw new Error(`${sub} は人の承認待ちです。/approve か /reject で決めます。`);
|
|
465
|
+
}
|
|
466
|
+
const role = held.reviewer?.role ?? "reviewer";
|
|
467
|
+
const who = await askLine(
|
|
468
|
+
`${role} の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`,
|
|
469
|
+
);
|
|
470
|
+
const [reviewerName, qualification] = who.split("/").map((part) => part.trim());
|
|
471
|
+
const interpretation = await askLine(
|
|
472
|
+
decision === "approve" ? "判断の本文(そのまま記録します)" : "認めない理由",
|
|
473
|
+
);
|
|
474
|
+
const { text } = await session.review({
|
|
475
|
+
approvalId: sub,
|
|
476
|
+
decision,
|
|
477
|
+
reviewer: {
|
|
478
|
+
name: reviewerName || who,
|
|
479
|
+
role,
|
|
480
|
+
...(qualification && { qualification }),
|
|
481
|
+
},
|
|
482
|
+
interpretation,
|
|
483
|
+
});
|
|
484
|
+
push("line", text);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
push("notice", message(err));
|
|
487
|
+
}
|
|
488
|
+
} else if (name === "review") {
|
|
489
|
+
push("notice", "/review <id> approve か /review <id> reject の形です。");
|
|
311
490
|
} else if (name === "resume") {
|
|
312
491
|
push(
|
|
313
492
|
"notice",
|
|
@@ -322,6 +501,7 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
322
501
|
closing ??= (async () => {
|
|
323
502
|
aborter?.abort();
|
|
324
503
|
settleQuestion();
|
|
504
|
+
settleApproval();
|
|
325
505
|
await running;
|
|
326
506
|
try {
|
|
327
507
|
await session.close();
|
|
@@ -336,7 +516,7 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
336
516
|
return closing;
|
|
337
517
|
}
|
|
338
518
|
|
|
339
|
-
|
|
519
|
+
const self: Controller = {
|
|
340
520
|
sessionId: session.id,
|
|
341
521
|
state: () => state,
|
|
342
522
|
subscribe(listener) {
|
|
@@ -346,6 +526,15 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
346
526
|
async submit(line) {
|
|
347
527
|
const text = line.trim();
|
|
348
528
|
if (text === "" || closing) return;
|
|
529
|
+
if (state.approval) {
|
|
530
|
+
push("notice", "承認を先に決めてください。数字か ↑↓ と Enter で選びます。");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (state.reason !== undefined) {
|
|
534
|
+
push("user", text);
|
|
535
|
+
settleApproval("reject", text);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
349
538
|
if (pending) {
|
|
350
539
|
push("user", text);
|
|
351
540
|
// Everything typed answers the question, except leaving: that takes the question back.
|
|
@@ -354,7 +543,10 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
354
543
|
return;
|
|
355
544
|
}
|
|
356
545
|
if (state.busy) {
|
|
357
|
-
|
|
546
|
+
// Typing while the agent works is not a mistake: the line waits its turn.
|
|
547
|
+
state.queued = [...state.queued, text];
|
|
548
|
+
push("notice", `順番待ち(${state.queued.length} 件): ${text}`);
|
|
549
|
+
notify();
|
|
358
550
|
return;
|
|
359
551
|
}
|
|
360
552
|
if (text.startsWith("/")) {
|
|
@@ -363,25 +555,56 @@ export async function createController(options: ControllerOptions): Promise<Cont
|
|
|
363
555
|
return;
|
|
364
556
|
}
|
|
365
557
|
push("user", text);
|
|
558
|
+
unreported = [];
|
|
366
559
|
await stoppable(async (signal) => {
|
|
367
560
|
try {
|
|
368
561
|
const result = await session.turn(text, { signal });
|
|
369
|
-
|
|
562
|
+
// What the work recorded is the agent's own writing, so it stands in when the turn
|
|
563
|
+
// ends with nothing said. Without this the person is left with a work that finished
|
|
564
|
+
// and no answer, which is what a model that skips its summary leaves behind.
|
|
565
|
+
const reply = result.reply.trim() === "" ? unreported.join("\n\n") : result.reply;
|
|
566
|
+
if (reply) push("assistant", reply);
|
|
370
567
|
const note = explain(result);
|
|
371
568
|
if (note) push("notice", note);
|
|
372
569
|
} catch (err) {
|
|
373
570
|
push("notice", message(err));
|
|
374
571
|
}
|
|
375
572
|
});
|
|
573
|
+
await drainQueue();
|
|
376
574
|
},
|
|
377
575
|
interrupt() {
|
|
378
576
|
if (!aborter) return false;
|
|
379
577
|
aborter.abort();
|
|
380
578
|
settleQuestion();
|
|
579
|
+
settleApproval();
|
|
381
580
|
return true;
|
|
382
581
|
},
|
|
582
|
+
moveApproval(delta) {
|
|
583
|
+
const approval = state.approval;
|
|
584
|
+
if (!approval) return;
|
|
585
|
+
const count = approval.choices.length;
|
|
586
|
+
state.approval = { ...approval, at: (approval.at + delta + count) % count };
|
|
587
|
+
notify();
|
|
588
|
+
},
|
|
589
|
+
decideApproval(choice) {
|
|
590
|
+
const approval = state.approval;
|
|
591
|
+
if (!approval) return;
|
|
592
|
+
const picked = choice ?? approval.choices[approval.at]?.key ?? "reject";
|
|
593
|
+
if (picked === "reject_with_reason") {
|
|
594
|
+
// The palette closes and the input box takes the reason; the loop still waits.
|
|
595
|
+
const shown = APPROVAL_CHOICES.find((c) => c.key === picked)?.label;
|
|
596
|
+
delete state.approval;
|
|
597
|
+
state.reason = "実行しない理由(社員エージェントに伝わります)";
|
|
598
|
+
if (shown) push("line", `> ${shown}`);
|
|
599
|
+
push("question", state.reason);
|
|
600
|
+
notify();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
settleApproval(picked);
|
|
604
|
+
},
|
|
383
605
|
close,
|
|
384
606
|
};
|
|
607
|
+
return self;
|
|
385
608
|
}
|
|
386
609
|
|
|
387
610
|
/** Lines that close a work in the screen: the CLI's closing lines without the summary, which the agent relays. */
|
package/src/tui/lines.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { displayWidth } from "../format.ts";
|
|
2
|
-
import { logoSegments
|
|
2
|
+
import { logoSegments } from "./banner.ts";
|
|
3
3
|
import type { Entry, EntryKind } from "./controller.ts";
|
|
4
|
+
import { markdownRows, type Span } from "./markdown.ts";
|
|
4
5
|
|
|
5
6
|
export interface ScreenLine {
|
|
6
7
|
kind: EntryKind | "blank";
|
|
8
|
+
/** The row as plain characters, marker included. */
|
|
7
9
|
text: string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
+
/** The row as styled pieces: a logo row, or a reply the screen drew from its markdown. */
|
|
11
|
+
spans?: Span[];
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** What starts a line of each kind. The continuation lines of a wrapped entry are indented to match. */
|
|
@@ -42,6 +44,20 @@ export function wrapText(text: string, width: number): string[] {
|
|
|
42
44
|
return out;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The rows of one reply, kept until the entry goes or the width changes. The screen redraws
|
|
49
|
+
* every entry whenever a line is added, and reading markdown is the expensive part of that.
|
|
50
|
+
*/
|
|
51
|
+
const drawn = new WeakMap<Entry, { width: number; rows: Span[][] }>();
|
|
52
|
+
|
|
53
|
+
export function rowsFor(entry: Entry, width: number): Span[][] {
|
|
54
|
+
const held = drawn.get(entry);
|
|
55
|
+
if (held && held.width === width) return held.rows;
|
|
56
|
+
const rows = markdownRows(entry.text, width);
|
|
57
|
+
drawn.set(entry, { width, rows });
|
|
58
|
+
return rows;
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
/** A blank row goes before an entry that starts something new: a message, a reply, a notice, a question. */
|
|
46
62
|
function startsBlock(kind: EntryKind, previous: EntryKind | undefined): boolean {
|
|
47
63
|
if (previous === undefined) return false;
|
|
@@ -59,14 +75,28 @@ export function screenLines(entries: readonly Entry[], width: number): ScreenLin
|
|
|
59
75
|
if (startsBlock(entry.kind, previous)) lines.push({ kind: "blank", text: "" });
|
|
60
76
|
if (entry.kind === "logo") {
|
|
61
77
|
// Never wrapped: a cut row of the wordmark reads better than a broken one.
|
|
62
|
-
lines.push({ kind: "logo", text: entry.text,
|
|
78
|
+
lines.push({ kind: "logo", text: entry.text, spans: logoSegments(entry.text) });
|
|
63
79
|
previous = entry.kind;
|
|
64
80
|
continue;
|
|
65
81
|
}
|
|
66
82
|
const marker = MARKERS[entry.kind];
|
|
67
83
|
const indent = " ".repeat(displayWidth(marker));
|
|
68
|
-
const
|
|
69
|
-
|
|
84
|
+
const room = Math.max(8, width - displayWidth(marker));
|
|
85
|
+
if (entry.kind === "assistant") {
|
|
86
|
+
// The reply is written in markdown; the screen draws it rather than showing its marks.
|
|
87
|
+
for (const [i, row] of rowsFor(entry, room).entries()) {
|
|
88
|
+
// A row with nothing on it is drawn as an empty one: no marker, no indent, no pieces.
|
|
89
|
+
if (row.length === 0) {
|
|
90
|
+
lines.push({ kind: entry.kind, text: "" });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const spans = [{ text: i === 0 ? marker : indent }, ...row];
|
|
94
|
+
lines.push({ kind: entry.kind, text: spans.map((s) => s.text).join(""), spans });
|
|
95
|
+
}
|
|
96
|
+
previous = entry.kind;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const [i, text] of wrapText(entry.text, room).entries()) {
|
|
70
100
|
lines.push({ kind: entry.kind, text: (i === 0 ? marker : indent) + text });
|
|
71
101
|
}
|
|
72
102
|
previous = entry.kind;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { marked, type Token, type Tokens } from "marked";
|
|
2
|
+
import { displayWidth } from "../format.ts";
|
|
3
|
+
|
|
4
|
+
/** A piece of a row that carries one style. A row is a list of these, drawn left to right. */
|
|
5
|
+
export interface Span {
|
|
6
|
+
text: string;
|
|
7
|
+
color?: string;
|
|
8
|
+
bold?: boolean;
|
|
9
|
+
italic?: boolean;
|
|
10
|
+
dim?: boolean;
|
|
11
|
+
strikethrough?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What each part of a reply looks like on the screen. */
|
|
15
|
+
const STYLE = {
|
|
16
|
+
heading: { bold: true, color: "cyan" },
|
|
17
|
+
code: { color: "green" },
|
|
18
|
+
link: { color: "blue" },
|
|
19
|
+
quote: { dim: true },
|
|
20
|
+
rule: { dim: true },
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
const QUOTE_MARKER = "▎ ";
|
|
24
|
+
const CODE_MARKER = "│ ";
|
|
25
|
+
const BULLETS = ["•", "◦", "‣"] as const;
|
|
26
|
+
|
|
27
|
+
function styled(text: string, style: Omit<Span, "text">): Span {
|
|
28
|
+
return { text, ...style };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The inline tokens of one block, flattened into styled pieces. */
|
|
32
|
+
function inline(tokens: Token[] | undefined, style: Omit<Span, "text">): Span[] {
|
|
33
|
+
if (!tokens) return [];
|
|
34
|
+
const spans: Span[] = [];
|
|
35
|
+
for (const token of tokens) {
|
|
36
|
+
switch (token.type) {
|
|
37
|
+
case "strong":
|
|
38
|
+
spans.push(...inline(token.tokens, { ...style, bold: true }));
|
|
39
|
+
break;
|
|
40
|
+
case "em":
|
|
41
|
+
spans.push(...inline(token.tokens, { ...style, italic: true }));
|
|
42
|
+
break;
|
|
43
|
+
case "del":
|
|
44
|
+
spans.push(...inline(token.tokens, { ...style, strikethrough: true }));
|
|
45
|
+
break;
|
|
46
|
+
case "codespan":
|
|
47
|
+
spans.push(styled((token as Tokens.Codespan).text, { ...style, ...STYLE.code }));
|
|
48
|
+
break;
|
|
49
|
+
case "link": {
|
|
50
|
+
const link = token as Tokens.Link;
|
|
51
|
+
spans.push(...inline(link.tokens, style));
|
|
52
|
+
// The label alone hides where the link goes, so the address follows it.
|
|
53
|
+
if (link.href && link.href !== textOf(link.tokens)) {
|
|
54
|
+
spans.push(styled(` (${link.href})`, { ...style, ...STYLE.link }));
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "br":
|
|
59
|
+
spans.push(styled("\n", style));
|
|
60
|
+
break;
|
|
61
|
+
case "escape":
|
|
62
|
+
case "text":
|
|
63
|
+
spans.push(
|
|
64
|
+
...((token as Tokens.Text).tokens
|
|
65
|
+
? inline((token as Tokens.Text).tokens, style)
|
|
66
|
+
: [styled((token as Tokens.Text).text, style)]),
|
|
67
|
+
);
|
|
68
|
+
break;
|
|
69
|
+
default:
|
|
70
|
+
spans.push(styled((token as { raw: string }).raw, style));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return spans;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function textOf(tokens: Token[] | undefined): string {
|
|
77
|
+
return inline(tokens, {})
|
|
78
|
+
.map((s) => s.text)
|
|
79
|
+
.join("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Breaks styled pieces into rows no wider than `width` display columns, the way the rest of the
|
|
84
|
+
* screen breaks plain text: at the character, so Japanese wraps where it should. Rows after the
|
|
85
|
+
* first start with `hanging`, which keeps a list item under its own marker.
|
|
86
|
+
*/
|
|
87
|
+
function wrap(spans: Span[], width: number, hanging = ""): Span[][] {
|
|
88
|
+
const limit = Math.max(4, width);
|
|
89
|
+
const rows: Span[][] = [];
|
|
90
|
+
let row: Span[] = [];
|
|
91
|
+
let used = 0;
|
|
92
|
+
const indent = () => (hanging === "" ? [] : [{ text: hanging }]);
|
|
93
|
+
const start = () => {
|
|
94
|
+
rows.push(row);
|
|
95
|
+
row = indent();
|
|
96
|
+
used = displayWidth(hanging);
|
|
97
|
+
};
|
|
98
|
+
for (const span of spans) {
|
|
99
|
+
for (const [i, part] of span.text.split("\n").entries()) {
|
|
100
|
+
// A line break inside a block starts a row of its own.
|
|
101
|
+
if (i > 0) start();
|
|
102
|
+
let piece = "";
|
|
103
|
+
for (const ch of part) {
|
|
104
|
+
const w = displayWidth(ch);
|
|
105
|
+
if (used + w > limit && (row.length > 0 || piece !== "")) {
|
|
106
|
+
if (piece !== "") row.push({ ...span, text: piece });
|
|
107
|
+
piece = "";
|
|
108
|
+
start();
|
|
109
|
+
}
|
|
110
|
+
piece += ch;
|
|
111
|
+
used += w;
|
|
112
|
+
}
|
|
113
|
+
if (piece !== "") row.push({ ...span, text: piece });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
rows.push(row);
|
|
117
|
+
return rows;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Puts `prefix` in front of every row, for a quote bar or a code bar. */
|
|
121
|
+
function prefixed(rows: Span[][], prefix: Span): Span[][] {
|
|
122
|
+
return rows.map((row) => [prefix, ...row]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function blockRows(tokens: Token[], width: number): Span[][] {
|
|
126
|
+
const rows: Span[][] = [];
|
|
127
|
+
for (const token of tokens) {
|
|
128
|
+
switch (token.type) {
|
|
129
|
+
case "space":
|
|
130
|
+
rows.push([]);
|
|
131
|
+
break;
|
|
132
|
+
case "heading":
|
|
133
|
+
rows.push(...wrap(inline(token.tokens, STYLE.heading), width));
|
|
134
|
+
break;
|
|
135
|
+
case "paragraph":
|
|
136
|
+
case "text":
|
|
137
|
+
rows.push(...wrap(inline(token.tokens ?? [], {}), width));
|
|
138
|
+
break;
|
|
139
|
+
case "code": {
|
|
140
|
+
const marker = styled(CODE_MARKER, STYLE.quote);
|
|
141
|
+
const body = (token as Tokens.Code).text.split("\n");
|
|
142
|
+
for (const line of body) {
|
|
143
|
+
rows.push(...prefixed(wrap([styled(line, STYLE.code)], width - 2), marker));
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "blockquote": {
|
|
148
|
+
const inner = blockRows((token as Tokens.Blockquote).tokens ?? [], width - 2);
|
|
149
|
+
rows.push(...prefixed(inner, styled(QUOTE_MARKER, STYLE.quote)));
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "list":
|
|
153
|
+
rows.push(...listRows(token as Tokens.List, width, 0));
|
|
154
|
+
break;
|
|
155
|
+
case "hr":
|
|
156
|
+
rows.push([styled("─".repeat(Math.max(4, width)), STYLE.rule)]);
|
|
157
|
+
break;
|
|
158
|
+
case "table":
|
|
159
|
+
// Aligning columns is its own piece of work; until then the source rows are shown as
|
|
160
|
+
// they were written, so nothing the model put in the table is lost.
|
|
161
|
+
for (const line of (token as Tokens.Table).raw.trimEnd().split("\n")) {
|
|
162
|
+
rows.push(...wrap([{ text: line }], width));
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
default:
|
|
166
|
+
for (const line of ((token as { raw?: string }).raw ?? "").trimEnd().split("\n")) {
|
|
167
|
+
rows.push(...wrap([{ text: line }], width));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function listRows(list: Tokens.List, width: number, depth: number): Span[][] {
|
|
175
|
+
const rows: Span[][] = [];
|
|
176
|
+
let number = Number(list.start || 1);
|
|
177
|
+
for (const item of list.items) {
|
|
178
|
+
const marker = list.ordered ? `${number++}. ` : `${BULLETS[depth % BULLETS.length]} `;
|
|
179
|
+
const indent = " ".repeat(displayWidth(marker));
|
|
180
|
+
const inner: Span[][] = [];
|
|
181
|
+
for (const token of item.tokens) {
|
|
182
|
+
if (token.type === "list") {
|
|
183
|
+
inner.push(...listRows(token as Tokens.List, width - displayWidth(marker), depth + 1));
|
|
184
|
+
} else {
|
|
185
|
+
inner.push(...blockRows([token], width - displayWidth(marker)));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const [i, row] of inner.entries()) {
|
|
189
|
+
rows.push([{ text: i === 0 ? marker : indent }, ...row]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return rows;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* How much of a reply is read as markdown. Reading it costs more than the square of its length
|
|
197
|
+
* (10,000 characters take about 0.14 seconds, 20,000 about 0.46, 140,000 over a minute), and the
|
|
198
|
+
* screen draws on one thread, so a longer reply would hold it. Above this the reply is shown as
|
|
199
|
+
* plain text: every character is still there, with its marks.
|
|
200
|
+
*/
|
|
201
|
+
const MAX_SOURCE = 20_000;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A reply as rows of styled pieces. The model writes markdown, so the screen shows the emphasis
|
|
205
|
+
* and the structure instead of the characters that mark them. What this does not draw yet is
|
|
206
|
+
* shown as it was written, never dropped.
|
|
207
|
+
*/
|
|
208
|
+
export function markdownRows(source: string, width: number): Span[][] {
|
|
209
|
+
if (source.length > MAX_SOURCE) return wrap([{ text: source }], width);
|
|
210
|
+
const rows = blockRows(marked.lexer(source), width);
|
|
211
|
+
while (rows.length > 0 && (rows[0]?.length ?? 0) === 0) rows.shift();
|
|
212
|
+
while (rows.length > 0 && (rows.at(-1)?.length ?? 0) === 0) rows.pop();
|
|
213
|
+
return rows.length > 0 ? rows : [[]];
|
|
214
|
+
}
|