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/dist/tui/controller.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { connectInMemory, createSession } from "@openshain/agent";
|
|
1
|
+
import { connectInMemory, createSession, } from "@openshain/agent";
|
|
2
2
|
import { loadConfig, OpenshainError, WorkStore, } from "@openshain/core";
|
|
3
3
|
import { createMcpServer } from "@openshain/mcp";
|
|
4
4
|
import { toolsList } from "../commands/tools.js";
|
|
5
5
|
import { workList, workShow } from "../commands/work.js";
|
|
6
|
-
import { plain } from "../format.js";
|
|
6
|
+
import { describeInput, plain } from "../format.js";
|
|
7
7
|
import { statusLabel } from "../labels.js";
|
|
8
|
+
import { previewCall } from "../preview.js";
|
|
8
9
|
import { progressLine, report } from "../report.js";
|
|
9
10
|
import { LOGO_ROWS, VERSION } from "./banner.js";
|
|
10
11
|
const HELP = [
|
|
11
12
|
"/work list Work の一覧",
|
|
12
13
|
"/work show <id> Work の詳細",
|
|
13
14
|
"/work resume <id> 止まった Work を候補にする。次の依頼がそれに沿えば続ける",
|
|
15
|
+
"/approvals 承認待ちの一覧",
|
|
16
|
+
"/approve <id> 承認して実行する。/reject <id> [理由] で拒否する",
|
|
17
|
+
"/review <id> approve|reject 資格者の判断を記録する。名前と本文を順に聞く",
|
|
14
18
|
"/tools 使える Tool",
|
|
15
19
|
"/quit 終わる",
|
|
16
20
|
"↑ ↓ 前に送った行を入力欄に呼び戻す。いちばん下は新しい入力",
|
|
@@ -18,8 +22,17 @@ const HELP = [
|
|
|
18
22
|
"ホイール、PageUp/PageDown 会話を遡る。送ると最新に戻る",
|
|
19
23
|
"Ctrl-C 動いている Work を止める。質問待ちなら質問を取り下げる。何も動いていなければ終わる",
|
|
20
24
|
];
|
|
25
|
+
/** The choices the screen offers for a held call, in the order they are shown. */
|
|
26
|
+
const APPROVAL_CHOICES = [
|
|
27
|
+
{ key: "approve", label: "はい。実行する" },
|
|
28
|
+
{ key: "always", label: "はい。この会話では同じ規則の呼び出しを常に承認する" },
|
|
29
|
+
{ key: "reject", label: "いいえ。実行しない" },
|
|
30
|
+
{ key: "reject_with_reason", label: "いいえ。理由を伝えて実行しない" },
|
|
31
|
+
];
|
|
21
32
|
/** What the session's model hears when the person stops a work that waits for their answer. */
|
|
22
33
|
const QUESTION_WITHDRAWN = "the person stopped the work while it waited for their answer; the question is still pending and the work can be resumed";
|
|
34
|
+
/** What the loop hears when the person leaves a held call undecided. */
|
|
35
|
+
const APPROVAL_WITHDRAWN = "the person left the approval undecided";
|
|
23
36
|
/**
|
|
24
37
|
* The state behind the screen: a session, the works it starts, and the lines to show. The
|
|
25
38
|
* conversation reaches the runtime only as an MCP client of the workspace's own server, the way
|
|
@@ -50,6 +63,7 @@ export async function createController(options) {
|
|
|
50
63
|
entries: [],
|
|
51
64
|
busy: false,
|
|
52
65
|
closed: false,
|
|
66
|
+
queued: [],
|
|
53
67
|
status: {
|
|
54
68
|
company: config.company.name,
|
|
55
69
|
model: `${config.model.provider}/${config.model.model}`,
|
|
@@ -81,10 +95,20 @@ export async function createController(options) {
|
|
|
81
95
|
notify();
|
|
82
96
|
};
|
|
83
97
|
let pending;
|
|
98
|
+
let deciding;
|
|
84
99
|
let aborter;
|
|
85
100
|
let running;
|
|
86
101
|
let closing;
|
|
87
102
|
const names = new Map();
|
|
103
|
+
/** Asks the person for one line and waits for it. The next line they type is the answer. */
|
|
104
|
+
const askLine = (question) => {
|
|
105
|
+
state.question = question;
|
|
106
|
+
push("question", question);
|
|
107
|
+
notify();
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
pending = { resolve, reject };
|
|
110
|
+
});
|
|
111
|
+
};
|
|
88
112
|
const ask = (workId, question) => {
|
|
89
113
|
state.question = question;
|
|
90
114
|
push("question", `${question}(${workId})`);
|
|
@@ -92,6 +116,51 @@ export async function createController(options) {
|
|
|
92
116
|
pending = { resolve, reject };
|
|
93
117
|
});
|
|
94
118
|
};
|
|
119
|
+
/** Shows a held call and waits for the person to pick one of the choices. */
|
|
120
|
+
const askApproval = async (approval) => {
|
|
121
|
+
const preview = await previewCall(workspaceRoot, {
|
|
122
|
+
name: approval.name,
|
|
123
|
+
input: approval.input,
|
|
124
|
+
}).catch((err) => [{ kind: "note", text: message(err) }]);
|
|
125
|
+
const title = `${approval.name} ${describeInput(approval.input)}`.trimEnd();
|
|
126
|
+
state.approval = {
|
|
127
|
+
approvalId: approval.approvalId,
|
|
128
|
+
title,
|
|
129
|
+
ruleId: approval.ruleId,
|
|
130
|
+
preview,
|
|
131
|
+
choices: APPROVAL_CHOICES,
|
|
132
|
+
at: 0,
|
|
133
|
+
};
|
|
134
|
+
push("question", `承認が要ります: ${title}`);
|
|
135
|
+
for (const line of preview) {
|
|
136
|
+
push("progress", `${line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " "}${line.text}`);
|
|
137
|
+
}
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
deciding = { resolve, reject };
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
/** Settles the approval being shown, or takes it back when there is no choice. */
|
|
143
|
+
const settleApproval = (choice, comment) => {
|
|
144
|
+
const waiting = deciding;
|
|
145
|
+
deciding = undefined;
|
|
146
|
+
if (state.reason !== undefined) {
|
|
147
|
+
delete state.reason;
|
|
148
|
+
notify();
|
|
149
|
+
}
|
|
150
|
+
if (state.approval !== undefined) {
|
|
151
|
+
const decided = choice ? APPROVAL_CHOICES.find((c) => c.key === choice)?.label : undefined;
|
|
152
|
+
delete state.approval;
|
|
153
|
+
if (decided)
|
|
154
|
+
push("line", `> ${decided}`);
|
|
155
|
+
notify();
|
|
156
|
+
}
|
|
157
|
+
if (!waiting)
|
|
158
|
+
return;
|
|
159
|
+
if (choice === undefined)
|
|
160
|
+
waiting.reject(new Error(APPROVAL_WITHDRAWN));
|
|
161
|
+
else
|
|
162
|
+
waiting.resolve({ choice, ...(comment !== undefined && comment !== "" && { comment }) });
|
|
163
|
+
};
|
|
95
164
|
/** Answers the pending question, or takes it back when there is no answer. */
|
|
96
165
|
const settleQuestion = (answer) => {
|
|
97
166
|
const waiting = pending;
|
|
@@ -113,6 +182,8 @@ export async function createController(options) {
|
|
|
113
182
|
push("progress", line.trimStart());
|
|
114
183
|
};
|
|
115
184
|
let sessionId;
|
|
185
|
+
/** Summaries of the works completed in this turn, until the agent reports them itself. */
|
|
186
|
+
let unreported = [];
|
|
116
187
|
const session = await createSession(client, {
|
|
117
188
|
model,
|
|
118
189
|
config,
|
|
@@ -142,6 +213,13 @@ export async function createController(options) {
|
|
|
142
213
|
id: workId,
|
|
143
214
|
status: event.type === "work.completed" ? "completed" : "failed",
|
|
144
215
|
};
|
|
216
|
+
if (event.type === "work.completed") {
|
|
217
|
+
// Held, not shown: the agent is the one who tells the person what happened. It is
|
|
218
|
+
// shown only if the turn ends without the agent saying anything (see submit).
|
|
219
|
+
const { summary } = event.payload;
|
|
220
|
+
if (summary.trim() !== "")
|
|
221
|
+
unreported.push(summary.trim());
|
|
222
|
+
}
|
|
145
223
|
return closingLines(workId);
|
|
146
224
|
}
|
|
147
225
|
// The work_* calls are the loop's own bookkeeping; the closing lines already say the work ended.
|
|
@@ -157,6 +235,7 @@ export async function createController(options) {
|
|
|
157
235
|
notify();
|
|
158
236
|
},
|
|
159
237
|
onInput: ask,
|
|
238
|
+
onApproval: askApproval,
|
|
160
239
|
});
|
|
161
240
|
sessionId = session.id;
|
|
162
241
|
state.status.agentName = session.agentName;
|
|
@@ -179,6 +258,15 @@ export async function createController(options) {
|
|
|
179
258
|
return "社員エージェントが続けられないと言っています。";
|
|
180
259
|
case "model_error":
|
|
181
260
|
return `model の呼び出しに失敗しました。${result.detail ?? ""}`.trim();
|
|
261
|
+
case "approval": {
|
|
262
|
+
const a = result.approval;
|
|
263
|
+
if (!a)
|
|
264
|
+
return "承認が要ります。/approvals で確かめてください。";
|
|
265
|
+
if (a.kind === "review") {
|
|
266
|
+
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 で記録します。`;
|
|
267
|
+
}
|
|
268
|
+
return `承認が要ります: ${a.name} ${describeInput(a.input)}(${a.approvalId})。/approve ${a.approvalId} で実行、/reject ${a.approvalId} で拒否します。`;
|
|
269
|
+
}
|
|
182
270
|
default:
|
|
183
271
|
return undefined;
|
|
184
272
|
}
|
|
@@ -201,6 +289,15 @@ export async function createController(options) {
|
|
|
201
289
|
notify();
|
|
202
290
|
}
|
|
203
291
|
};
|
|
292
|
+
/** Sends what the person typed while the agent was working, oldest first. */
|
|
293
|
+
const drainQueue = async () => {
|
|
294
|
+
while (state.queued.length > 0 && !closing) {
|
|
295
|
+
const [next, ...rest] = state.queued;
|
|
296
|
+
state.queued = rest;
|
|
297
|
+
notify();
|
|
298
|
+
await self.submit(next);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
204
301
|
const capture = async (fn) => {
|
|
205
302
|
try {
|
|
206
303
|
await fn((line) => push("line", line));
|
|
@@ -235,6 +332,65 @@ export async function createController(options) {
|
|
|
235
332
|
push("notice", message(err));
|
|
236
333
|
}
|
|
237
334
|
}
|
|
335
|
+
else if (name === "approvals") {
|
|
336
|
+
try {
|
|
337
|
+
const held = await session.approvals();
|
|
338
|
+
if (held.length === 0)
|
|
339
|
+
push("line", "承認待ちはありません。");
|
|
340
|
+
for (const a of held) {
|
|
341
|
+
push("line", `${a.approvalId} ${a.name} ${describeInput(a.input)} (${a.workId})`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
push("notice", message(err));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else if ((name === "approve" || name === "reject") && sub) {
|
|
349
|
+
try {
|
|
350
|
+
const comment = args.slice(1).join(" ");
|
|
351
|
+
const { text } = await session.decide(sub, name, comment || undefined);
|
|
352
|
+
push("line", text);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
push("notice", message(err));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else if (name === "approve" || name === "reject") {
|
|
359
|
+
push("notice", `/${name} には承認の id が要ります。/approvals で確かめてください。`);
|
|
360
|
+
}
|
|
361
|
+
else if (name === "review" && sub && (args[1] === "approve" || args[1] === "reject")) {
|
|
362
|
+
const decision = args[1];
|
|
363
|
+
try {
|
|
364
|
+
// The rule already says which role has to decide; the person only says who they are.
|
|
365
|
+
const held = (await session.approvals()).find((a) => a.approvalId === sub);
|
|
366
|
+
if (!held)
|
|
367
|
+
throw new Error(`${sub} は承認待ちにありません。/approvals で確かめてください。`);
|
|
368
|
+
if (held.kind !== "review") {
|
|
369
|
+
throw new Error(`${sub} は人の承認待ちです。/approve か /reject で決めます。`);
|
|
370
|
+
}
|
|
371
|
+
const role = held.reviewer?.role ?? "reviewer";
|
|
372
|
+
const who = await askLine(`${role} の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`);
|
|
373
|
+
const [reviewerName, qualification] = who.split("/").map((part) => part.trim());
|
|
374
|
+
const interpretation = await askLine(decision === "approve" ? "判断の本文(そのまま記録します)" : "認めない理由");
|
|
375
|
+
const { text } = await session.review({
|
|
376
|
+
approvalId: sub,
|
|
377
|
+
decision,
|
|
378
|
+
reviewer: {
|
|
379
|
+
name: reviewerName || who,
|
|
380
|
+
role,
|
|
381
|
+
...(qualification && { qualification }),
|
|
382
|
+
},
|
|
383
|
+
interpretation,
|
|
384
|
+
});
|
|
385
|
+
push("line", text);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
push("notice", message(err));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (name === "review") {
|
|
392
|
+
push("notice", "/review <id> approve か /review <id> reject の形です。");
|
|
393
|
+
}
|
|
238
394
|
else if (name === "resume") {
|
|
239
395
|
push("notice", "セッションの再開はまだありません。止まった Work を続けるなら /work resume <id> です。");
|
|
240
396
|
}
|
|
@@ -246,6 +402,7 @@ export async function createController(options) {
|
|
|
246
402
|
closing ??= (async () => {
|
|
247
403
|
aborter?.abort();
|
|
248
404
|
settleQuestion();
|
|
405
|
+
settleApproval();
|
|
249
406
|
await running;
|
|
250
407
|
try {
|
|
251
408
|
await session.close();
|
|
@@ -261,7 +418,7 @@ export async function createController(options) {
|
|
|
261
418
|
})();
|
|
262
419
|
return closing;
|
|
263
420
|
}
|
|
264
|
-
|
|
421
|
+
const self = {
|
|
265
422
|
sessionId: session.id,
|
|
266
423
|
state: () => state,
|
|
267
424
|
subscribe(listener) {
|
|
@@ -272,6 +429,15 @@ export async function createController(options) {
|
|
|
272
429
|
const text = line.trim();
|
|
273
430
|
if (text === "" || closing)
|
|
274
431
|
return;
|
|
432
|
+
if (state.approval) {
|
|
433
|
+
push("notice", "承認を先に決めてください。数字か ↑↓ と Enter で選びます。");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (state.reason !== undefined) {
|
|
437
|
+
push("user", text);
|
|
438
|
+
settleApproval("reject", text);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
275
441
|
if (pending) {
|
|
276
442
|
push("user", text);
|
|
277
443
|
// Everything typed answers the question, except leaving: that takes the question back.
|
|
@@ -282,7 +448,10 @@ export async function createController(options) {
|
|
|
282
448
|
return;
|
|
283
449
|
}
|
|
284
450
|
if (state.busy) {
|
|
285
|
-
|
|
451
|
+
// Typing while the agent works is not a mistake: the line waits its turn.
|
|
452
|
+
state.queued = [...state.queued, text];
|
|
453
|
+
push("notice", `順番待ち(${state.queued.length} 件): ${text}`);
|
|
454
|
+
notify();
|
|
286
455
|
return;
|
|
287
456
|
}
|
|
288
457
|
if (text.startsWith("/")) {
|
|
@@ -291,11 +460,16 @@ export async function createController(options) {
|
|
|
291
460
|
return;
|
|
292
461
|
}
|
|
293
462
|
push("user", text);
|
|
463
|
+
unreported = [];
|
|
294
464
|
await stoppable(async (signal) => {
|
|
295
465
|
try {
|
|
296
466
|
const result = await session.turn(text, { signal });
|
|
297
|
-
|
|
298
|
-
|
|
467
|
+
// What the work recorded is the agent's own writing, so it stands in when the turn
|
|
468
|
+
// ends with nothing said. Without this the person is left with a work that finished
|
|
469
|
+
// and no answer, which is what a model that skips its summary leaves behind.
|
|
470
|
+
const reply = result.reply.trim() === "" ? unreported.join("\n\n") : result.reply;
|
|
471
|
+
if (reply)
|
|
472
|
+
push("assistant", reply);
|
|
299
473
|
const note = explain(result);
|
|
300
474
|
if (note)
|
|
301
475
|
push("notice", note);
|
|
@@ -304,16 +478,45 @@ export async function createController(options) {
|
|
|
304
478
|
push("notice", message(err));
|
|
305
479
|
}
|
|
306
480
|
});
|
|
481
|
+
await drainQueue();
|
|
307
482
|
},
|
|
308
483
|
interrupt() {
|
|
309
484
|
if (!aborter)
|
|
310
485
|
return false;
|
|
311
486
|
aborter.abort();
|
|
312
487
|
settleQuestion();
|
|
488
|
+
settleApproval();
|
|
313
489
|
return true;
|
|
314
490
|
},
|
|
491
|
+
moveApproval(delta) {
|
|
492
|
+
const approval = state.approval;
|
|
493
|
+
if (!approval)
|
|
494
|
+
return;
|
|
495
|
+
const count = approval.choices.length;
|
|
496
|
+
state.approval = { ...approval, at: (approval.at + delta + count) % count };
|
|
497
|
+
notify();
|
|
498
|
+
},
|
|
499
|
+
decideApproval(choice) {
|
|
500
|
+
const approval = state.approval;
|
|
501
|
+
if (!approval)
|
|
502
|
+
return;
|
|
503
|
+
const picked = choice ?? approval.choices[approval.at]?.key ?? "reject";
|
|
504
|
+
if (picked === "reject_with_reason") {
|
|
505
|
+
// The palette closes and the input box takes the reason; the loop still waits.
|
|
506
|
+
const shown = APPROVAL_CHOICES.find((c) => c.key === picked)?.label;
|
|
507
|
+
delete state.approval;
|
|
508
|
+
state.reason = "実行しない理由(社員エージェントに伝わります)";
|
|
509
|
+
if (shown)
|
|
510
|
+
push("line", `> ${shown}`);
|
|
511
|
+
push("question", state.reason);
|
|
512
|
+
notify();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
settleApproval(picked);
|
|
516
|
+
},
|
|
315
517
|
close,
|
|
316
518
|
};
|
|
519
|
+
return self;
|
|
317
520
|
}
|
|
318
521
|
/** Lines that close a work in the screen: the CLI's closing lines without the summary, which the agent relays. */
|
|
319
522
|
export async function workReport(store, workId) {
|
package/dist/tui/lines.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { type Segment } from "./banner.ts";
|
|
2
1
|
import type { Entry, EntryKind } from "./controller.ts";
|
|
2
|
+
import { type Span } from "./markdown.ts";
|
|
3
3
|
export interface ScreenLine {
|
|
4
4
|
kind: EntryKind | "blank";
|
|
5
|
+
/** The row as plain characters, marker included. */
|
|
5
6
|
text: string;
|
|
6
|
-
/**
|
|
7
|
-
|
|
7
|
+
/** The row as styled pieces: a logo row, or a reply the screen drew from its markdown. */
|
|
8
|
+
spans?: Span[];
|
|
8
9
|
}
|
|
9
10
|
/** Breaks text into lines no wider than `width` display columns, counting East Asian wide characters as two. */
|
|
10
11
|
export declare function wrapText(text: string, width: number): string[];
|
|
12
|
+
export declare function rowsFor(entry: Entry, width: number): Span[][];
|
|
11
13
|
/** The rows the screen shows for the entries, wrapped to the width, with markers and blank rows between blocks. */
|
|
12
14
|
export declare function screenLines(entries: readonly Entry[], width: number): ScreenLine[];
|
package/dist/tui/lines.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { displayWidth } from "../format.js";
|
|
2
2
|
import { logoSegments } from "./banner.js";
|
|
3
|
+
import { markdownRows } from "./markdown.js";
|
|
3
4
|
/** What starts a line of each kind. The continuation lines of a wrapped entry are indented to match. */
|
|
4
5
|
const MARKERS = {
|
|
5
6
|
user: "> ",
|
|
@@ -31,6 +32,19 @@ export function wrapText(text, width) {
|
|
|
31
32
|
}
|
|
32
33
|
return out;
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The rows of one reply, kept until the entry goes or the width changes. The screen redraws
|
|
37
|
+
* every entry whenever a line is added, and reading markdown is the expensive part of that.
|
|
38
|
+
*/
|
|
39
|
+
const drawn = new WeakMap();
|
|
40
|
+
export function rowsFor(entry, width) {
|
|
41
|
+
const held = drawn.get(entry);
|
|
42
|
+
if (held && held.width === width)
|
|
43
|
+
return held.rows;
|
|
44
|
+
const rows = markdownRows(entry.text, width);
|
|
45
|
+
drawn.set(entry, { width, rows });
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
34
48
|
/** A blank row goes before an entry that starts something new: a message, a reply, a notice, a question. */
|
|
35
49
|
function startsBlock(kind, previous) {
|
|
36
50
|
if (previous === undefined)
|
|
@@ -52,14 +66,28 @@ export function screenLines(entries, width) {
|
|
|
52
66
|
lines.push({ kind: "blank", text: "" });
|
|
53
67
|
if (entry.kind === "logo") {
|
|
54
68
|
// Never wrapped: a cut row of the wordmark reads better than a broken one.
|
|
55
|
-
lines.push({ kind: "logo", text: entry.text,
|
|
69
|
+
lines.push({ kind: "logo", text: entry.text, spans: logoSegments(entry.text) });
|
|
56
70
|
previous = entry.kind;
|
|
57
71
|
continue;
|
|
58
72
|
}
|
|
59
73
|
const marker = MARKERS[entry.kind];
|
|
60
74
|
const indent = " ".repeat(displayWidth(marker));
|
|
61
|
-
const
|
|
62
|
-
|
|
75
|
+
const room = Math.max(8, width - displayWidth(marker));
|
|
76
|
+
if (entry.kind === "assistant") {
|
|
77
|
+
// The reply is written in markdown; the screen draws it rather than showing its marks.
|
|
78
|
+
for (const [i, row] of rowsFor(entry, room).entries()) {
|
|
79
|
+
// A row with nothing on it is drawn as an empty one: no marker, no indent, no pieces.
|
|
80
|
+
if (row.length === 0) {
|
|
81
|
+
lines.push({ kind: entry.kind, text: "" });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const spans = [{ text: i === 0 ? marker : indent }, ...row];
|
|
85
|
+
lines.push({ kind: entry.kind, text: spans.map((s) => s.text).join(""), spans });
|
|
86
|
+
}
|
|
87
|
+
previous = entry.kind;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const [i, text] of wrapText(entry.text, room).entries()) {
|
|
63
91
|
lines.push({ kind: entry.kind, text: (i === 0 ? marker : indent) + text });
|
|
64
92
|
}
|
|
65
93
|
previous = entry.kind;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A piece of a row that carries one style. A row is a list of these, drawn left to right. */
|
|
2
|
+
export interface Span {
|
|
3
|
+
text: string;
|
|
4
|
+
color?: string;
|
|
5
|
+
bold?: boolean;
|
|
6
|
+
italic?: boolean;
|
|
7
|
+
dim?: boolean;
|
|
8
|
+
strikethrough?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A reply as rows of styled pieces. The model writes markdown, so the screen shows the emphasis
|
|
12
|
+
* and the structure instead of the characters that mark them. What this does not draw yet is
|
|
13
|
+
* shown as it was written, never dropped.
|
|
14
|
+
*/
|
|
15
|
+
export declare function markdownRows(source: string, width: number): Span[][];
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { displayWidth } from "../format.js";
|
|
3
|
+
/** What each part of a reply looks like on the screen. */
|
|
4
|
+
const STYLE = {
|
|
5
|
+
heading: { bold: true, color: "cyan" },
|
|
6
|
+
code: { color: "green" },
|
|
7
|
+
link: { color: "blue" },
|
|
8
|
+
quote: { dim: true },
|
|
9
|
+
rule: { dim: true },
|
|
10
|
+
};
|
|
11
|
+
const QUOTE_MARKER = "▎ ";
|
|
12
|
+
const CODE_MARKER = "│ ";
|
|
13
|
+
const BULLETS = ["•", "◦", "‣"];
|
|
14
|
+
function styled(text, style) {
|
|
15
|
+
return { text, ...style };
|
|
16
|
+
}
|
|
17
|
+
/** The inline tokens of one block, flattened into styled pieces. */
|
|
18
|
+
function inline(tokens, style) {
|
|
19
|
+
if (!tokens)
|
|
20
|
+
return [];
|
|
21
|
+
const spans = [];
|
|
22
|
+
for (const token of tokens) {
|
|
23
|
+
switch (token.type) {
|
|
24
|
+
case "strong":
|
|
25
|
+
spans.push(...inline(token.tokens, { ...style, bold: true }));
|
|
26
|
+
break;
|
|
27
|
+
case "em":
|
|
28
|
+
spans.push(...inline(token.tokens, { ...style, italic: true }));
|
|
29
|
+
break;
|
|
30
|
+
case "del":
|
|
31
|
+
spans.push(...inline(token.tokens, { ...style, strikethrough: true }));
|
|
32
|
+
break;
|
|
33
|
+
case "codespan":
|
|
34
|
+
spans.push(styled(token.text, { ...style, ...STYLE.code }));
|
|
35
|
+
break;
|
|
36
|
+
case "link": {
|
|
37
|
+
const link = token;
|
|
38
|
+
spans.push(...inline(link.tokens, style));
|
|
39
|
+
// The label alone hides where the link goes, so the address follows it.
|
|
40
|
+
if (link.href && link.href !== textOf(link.tokens)) {
|
|
41
|
+
spans.push(styled(` (${link.href})`, { ...style, ...STYLE.link }));
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "br":
|
|
46
|
+
spans.push(styled("\n", style));
|
|
47
|
+
break;
|
|
48
|
+
case "escape":
|
|
49
|
+
case "text":
|
|
50
|
+
spans.push(...(token.tokens
|
|
51
|
+
? inline(token.tokens, style)
|
|
52
|
+
: [styled(token.text, style)]));
|
|
53
|
+
break;
|
|
54
|
+
default:
|
|
55
|
+
spans.push(styled(token.raw, style));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return spans;
|
|
59
|
+
}
|
|
60
|
+
function textOf(tokens) {
|
|
61
|
+
return inline(tokens, {})
|
|
62
|
+
.map((s) => s.text)
|
|
63
|
+
.join("");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Breaks styled pieces into rows no wider than `width` display columns, the way the rest of the
|
|
67
|
+
* screen breaks plain text: at the character, so Japanese wraps where it should. Rows after the
|
|
68
|
+
* first start with `hanging`, which keeps a list item under its own marker.
|
|
69
|
+
*/
|
|
70
|
+
function wrap(spans, width, hanging = "") {
|
|
71
|
+
const limit = Math.max(4, width);
|
|
72
|
+
const rows = [];
|
|
73
|
+
let row = [];
|
|
74
|
+
let used = 0;
|
|
75
|
+
const indent = () => (hanging === "" ? [] : [{ text: hanging }]);
|
|
76
|
+
const start = () => {
|
|
77
|
+
rows.push(row);
|
|
78
|
+
row = indent();
|
|
79
|
+
used = displayWidth(hanging);
|
|
80
|
+
};
|
|
81
|
+
for (const span of spans) {
|
|
82
|
+
for (const [i, part] of span.text.split("\n").entries()) {
|
|
83
|
+
// A line break inside a block starts a row of its own.
|
|
84
|
+
if (i > 0)
|
|
85
|
+
start();
|
|
86
|
+
let piece = "";
|
|
87
|
+
for (const ch of part) {
|
|
88
|
+
const w = displayWidth(ch);
|
|
89
|
+
if (used + w > limit && (row.length > 0 || piece !== "")) {
|
|
90
|
+
if (piece !== "")
|
|
91
|
+
row.push({ ...span, text: piece });
|
|
92
|
+
piece = "";
|
|
93
|
+
start();
|
|
94
|
+
}
|
|
95
|
+
piece += ch;
|
|
96
|
+
used += w;
|
|
97
|
+
}
|
|
98
|
+
if (piece !== "")
|
|
99
|
+
row.push({ ...span, text: piece });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
rows.push(row);
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
105
|
+
/** Puts `prefix` in front of every row, for a quote bar or a code bar. */
|
|
106
|
+
function prefixed(rows, prefix) {
|
|
107
|
+
return rows.map((row) => [prefix, ...row]);
|
|
108
|
+
}
|
|
109
|
+
function blockRows(tokens, width) {
|
|
110
|
+
const rows = [];
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
switch (token.type) {
|
|
113
|
+
case "space":
|
|
114
|
+
rows.push([]);
|
|
115
|
+
break;
|
|
116
|
+
case "heading":
|
|
117
|
+
rows.push(...wrap(inline(token.tokens, STYLE.heading), width));
|
|
118
|
+
break;
|
|
119
|
+
case "paragraph":
|
|
120
|
+
case "text":
|
|
121
|
+
rows.push(...wrap(inline(token.tokens ?? [], {}), width));
|
|
122
|
+
break;
|
|
123
|
+
case "code": {
|
|
124
|
+
const marker = styled(CODE_MARKER, STYLE.quote);
|
|
125
|
+
const body = token.text.split("\n");
|
|
126
|
+
for (const line of body) {
|
|
127
|
+
rows.push(...prefixed(wrap([styled(line, STYLE.code)], width - 2), marker));
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "blockquote": {
|
|
132
|
+
const inner = blockRows(token.tokens ?? [], width - 2);
|
|
133
|
+
rows.push(...prefixed(inner, styled(QUOTE_MARKER, STYLE.quote)));
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "list":
|
|
137
|
+
rows.push(...listRows(token, width, 0));
|
|
138
|
+
break;
|
|
139
|
+
case "hr":
|
|
140
|
+
rows.push([styled("─".repeat(Math.max(4, width)), STYLE.rule)]);
|
|
141
|
+
break;
|
|
142
|
+
case "table":
|
|
143
|
+
// Aligning columns is its own piece of work; until then the source rows are shown as
|
|
144
|
+
// they were written, so nothing the model put in the table is lost.
|
|
145
|
+
for (const line of token.raw.trimEnd().split("\n")) {
|
|
146
|
+
rows.push(...wrap([{ text: line }], width));
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
for (const line of (token.raw ?? "").trimEnd().split("\n")) {
|
|
151
|
+
rows.push(...wrap([{ text: line }], width));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return rows;
|
|
156
|
+
}
|
|
157
|
+
function listRows(list, width, depth) {
|
|
158
|
+
const rows = [];
|
|
159
|
+
let number = Number(list.start || 1);
|
|
160
|
+
for (const item of list.items) {
|
|
161
|
+
const marker = list.ordered ? `${number++}. ` : `${BULLETS[depth % BULLETS.length]} `;
|
|
162
|
+
const indent = " ".repeat(displayWidth(marker));
|
|
163
|
+
const inner = [];
|
|
164
|
+
for (const token of item.tokens) {
|
|
165
|
+
if (token.type === "list") {
|
|
166
|
+
inner.push(...listRows(token, width - displayWidth(marker), depth + 1));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
inner.push(...blockRows([token], width - displayWidth(marker)));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const [i, row] of inner.entries()) {
|
|
173
|
+
rows.push([{ text: i === 0 ? marker : indent }, ...row]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return rows;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* How much of a reply is read as markdown. Reading it costs more than the square of its length
|
|
180
|
+
* (10,000 characters take about 0.14 seconds, 20,000 about 0.46, 140,000 over a minute), and the
|
|
181
|
+
* screen draws on one thread, so a longer reply would hold it. Above this the reply is shown as
|
|
182
|
+
* plain text: every character is still there, with its marks.
|
|
183
|
+
*/
|
|
184
|
+
const MAX_SOURCE = 20_000;
|
|
185
|
+
/**
|
|
186
|
+
* A reply as rows of styled pieces. The model writes markdown, so the screen shows the emphasis
|
|
187
|
+
* and the structure instead of the characters that mark them. What this does not draw yet is
|
|
188
|
+
* shown as it was written, never dropped.
|
|
189
|
+
*/
|
|
190
|
+
export function markdownRows(source, width) {
|
|
191
|
+
if (source.length > MAX_SOURCE)
|
|
192
|
+
return wrap([{ text: source }], width);
|
|
193
|
+
const rows = blockRows(marked.lexer(source), width);
|
|
194
|
+
while (rows.length > 0 && (rows[0]?.length ?? 0) === 0)
|
|
195
|
+
rows.shift();
|
|
196
|
+
while (rows.length > 0 && (rows.at(-1)?.length ?? 0) === 0)
|
|
197
|
+
rows.pop();
|
|
198
|
+
return rows.length > 0 ? rows : [[]];
|
|
199
|
+
}
|