openshain 0.2.0 → 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/bin.js +4 -33
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +7 -6
- package/dist/commands/tools.js +1 -2
- package/dist/commands/work.d.ts +1 -9
- package/dist/commands/work.js +9 -22
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/labels.d.ts +1 -2
- package/dist/labels.js +3 -0
- package/dist/preview.d.ts +13 -0
- package/dist/preview.js +141 -0
- package/dist/report.d.ts +6 -0
- package/dist/{commands/run.js → report.js} +6 -38
- 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 +30 -5
- package/dist/tui/controller.js +306 -77
- package/dist/tui/index.js +1 -1
- 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/dist/usage.d.ts +1 -1
- package/dist/usage.js +1 -1
- package/dist/workspace.js +1 -1
- package/package.json +9 -7
- package/src/bin.ts +4 -38
- package/src/commands/init.ts +7 -6
- package/src/commands/tools.ts +7 -2
- package/src/commands/work.ts +10 -34
- package/src/index.ts +1 -10
- package/src/labels.ts +4 -2
- package/src/preview.ts +157 -0
- package/src/{commands/run.ts → report.ts} +6 -59
- package/src/tui/app.tsx +75 -13
- package/src/tui/banner.ts +4 -10
- package/src/tui/controller.ts +338 -77
- package/src/tui/index.ts +3 -1
- package/src/tui/lines.ts +36 -6
- package/src/tui/markdown.ts +214 -0
- package/src/usage.ts +1 -2
- package/src/workspace.ts +1 -1
- package/dist/commands/run.d.ts +0 -22
package/src/preview.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolveWorkspacePath } from "@openshain/core";
|
|
3
|
+
import { csvText } from "@openshain/tools";
|
|
4
|
+
|
|
5
|
+
/** How much of a change the screen shows before it says the rest is cut. */
|
|
6
|
+
const MAX_LINES = 24;
|
|
7
|
+
/** Above this many lines on either side, the diff is replaced by the line counts. */
|
|
8
|
+
const MAX_DIFF_LINES = 400;
|
|
9
|
+
/** A single line longer than this is cut: one line must not fill the screen. */
|
|
10
|
+
const MAX_LINE_CHARS = 300;
|
|
11
|
+
|
|
12
|
+
export interface PreviewLine {
|
|
13
|
+
kind: "added" | "removed" | "context" | "note";
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The tools whose input the screen can render as the file it would leave behind. */
|
|
18
|
+
const DIFFABLE: ReadonlySet<string> = new Set(["fs_write", "csv_write"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a held call would change, for the person about to approve it. A call of a tool that writes
|
|
22
|
+
* a whole file is shown as a line diff against the file as it is now; anything else is shown as
|
|
23
|
+
* its input. Reads the file directly: this is the person's own workspace, on their own screen.
|
|
24
|
+
*/
|
|
25
|
+
export async function previewCall(
|
|
26
|
+
workspaceRoot: string,
|
|
27
|
+
call: { name: string; input: unknown },
|
|
28
|
+
): Promise<PreviewLine[]> {
|
|
29
|
+
if (!DIFFABLE.has(call.name)) return [{ kind: "note", text: JSON.stringify(call.input) }];
|
|
30
|
+
const input = (call.input ?? {}) as {
|
|
31
|
+
path?: unknown;
|
|
32
|
+
content?: unknown;
|
|
33
|
+
rows?: unknown;
|
|
34
|
+
columns?: unknown;
|
|
35
|
+
};
|
|
36
|
+
const path = typeof input.path === "string" ? input.path : undefined;
|
|
37
|
+
const content =
|
|
38
|
+
typeof input.content === "string"
|
|
39
|
+
? input.content
|
|
40
|
+
: Array.isArray(input.rows)
|
|
41
|
+
? csvText(
|
|
42
|
+
input.rows as Record<string, unknown>[],
|
|
43
|
+
Array.isArray(input.columns) ? (input.columns as string[]) : undefined,
|
|
44
|
+
)
|
|
45
|
+
: undefined;
|
|
46
|
+
if (path === undefined || content === undefined) {
|
|
47
|
+
return [{ kind: "note", text: JSON.stringify(call.input) }];
|
|
48
|
+
}
|
|
49
|
+
// The same guard the tools run under: a path outside the workspace, a reserved one or a
|
|
50
|
+
// symlink that leads out is refused here too, so the screen never shows what the call cannot
|
|
51
|
+
// touch. The model chooses this path; the person is about to read what it says.
|
|
52
|
+
let resolved: string;
|
|
53
|
+
try {
|
|
54
|
+
resolved = await resolveWorkspacePath(workspaceRoot, path);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
kind: "note",
|
|
59
|
+
text: `${path} は読めません(${err instanceof Error ? err.message : String(err)})。この呼び出しは実行しても拒否されます。`,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
const before = await readFile(resolved, "utf8").catch(() => undefined);
|
|
64
|
+
if (before === undefined) {
|
|
65
|
+
const lines = content.split("\n");
|
|
66
|
+
return cap([
|
|
67
|
+
{ kind: "note", text: `${path} を新しく作ります(${lines.length} 行)` },
|
|
68
|
+
...lines.map((text): PreviewLine => ({ kind: "added", text })),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
const oldLines = before.split("\n");
|
|
72
|
+
const newLines = content.split("\n");
|
|
73
|
+
if (oldLines.length > MAX_DIFF_LINES || newLines.length > MAX_DIFF_LINES) {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
kind: "note",
|
|
77
|
+
text: `${path} を書き換えます(${oldLines.length} 行 → ${newLines.length} 行。大きいので差分は出しません)`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
const body = diff(oldLines, newLines);
|
|
82
|
+
return cap([{ kind: "note", text: `${path} を書き換えます` }, ...body]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cap(lines: PreviewLine[]): PreviewLine[] {
|
|
86
|
+
const short = lines.map((line) =>
|
|
87
|
+
line.text.length > MAX_LINE_CHARS
|
|
88
|
+
? { ...line, text: `${line.text.slice(0, MAX_LINE_CHARS)}…` }
|
|
89
|
+
: line,
|
|
90
|
+
);
|
|
91
|
+
if (short.length <= MAX_LINES) return short;
|
|
92
|
+
const rest = short.length - MAX_LINES;
|
|
93
|
+
return [...short.slice(0, MAX_LINES), { kind: "note", text: `ほか ${rest} 行` }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A line diff by the longest common subsequence, with the unchanged lines around a change kept
|
|
98
|
+
* as context. Small files only; the caller checks the size first.
|
|
99
|
+
*/
|
|
100
|
+
function diff(before: string[], after: string[]): PreviewLine[] {
|
|
101
|
+
const table: number[][] = Array.from({ length: before.length + 1 }, () =>
|
|
102
|
+
new Array<number>(after.length + 1).fill(0),
|
|
103
|
+
);
|
|
104
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
105
|
+
for (let j = after.length - 1; j >= 0; j--) {
|
|
106
|
+
const row = table[i] as number[];
|
|
107
|
+
const next = table[i + 1] as number[];
|
|
108
|
+
row[j] =
|
|
109
|
+
before[i] === after[j]
|
|
110
|
+
? (next[j + 1] as number) + 1
|
|
111
|
+
: Math.max(next[j] as number, row[j + 1] as number);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const all: PreviewLine[] = [];
|
|
115
|
+
let i = 0;
|
|
116
|
+
let j = 0;
|
|
117
|
+
while (i < before.length && j < after.length) {
|
|
118
|
+
if (before[i] === after[j]) {
|
|
119
|
+
all.push({ kind: "context", text: before[i] as string });
|
|
120
|
+
i++;
|
|
121
|
+
j++;
|
|
122
|
+
} else if ((table[i + 1]?.[j] ?? 0) >= (table[i]?.[j + 1] ?? 0)) {
|
|
123
|
+
all.push({ kind: "removed", text: before[i] as string });
|
|
124
|
+
i++;
|
|
125
|
+
} else {
|
|
126
|
+
all.push({ kind: "added", text: after[j] as string });
|
|
127
|
+
j++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (; i < before.length; i++) all.push({ kind: "removed", text: before[i] as string });
|
|
131
|
+
for (; j < after.length; j++) all.push({ kind: "added", text: after[j] as string });
|
|
132
|
+
return trimContext(all);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Keeps two unchanged lines on each side of a change and marks what was left out. */
|
|
136
|
+
function trimContext(lines: PreviewLine[], keep = 2): PreviewLine[] {
|
|
137
|
+
const wanted = new Set<number>();
|
|
138
|
+
lines.forEach((line, index) => {
|
|
139
|
+
if (line.kind === "context") return;
|
|
140
|
+
for (let k = index - keep; k <= index + keep; k++) wanted.add(k);
|
|
141
|
+
});
|
|
142
|
+
const out: PreviewLine[] = [];
|
|
143
|
+
let skipped = 0;
|
|
144
|
+
lines.forEach((line, index) => {
|
|
145
|
+
if (wanted.has(index)) {
|
|
146
|
+
if (skipped > 0) {
|
|
147
|
+
out.push({ kind: "note", text: `… 変わらない ${skipped} 行 …` });
|
|
148
|
+
skipped = 0;
|
|
149
|
+
}
|
|
150
|
+
out.push(line);
|
|
151
|
+
} else {
|
|
152
|
+
skipped++;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
if (skipped > 0) out.push({ kind: "note", text: `… 変わらない ${skipped} 行 …` });
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
@@ -1,66 +1,13 @@
|
|
|
1
|
-
import { pendingQuestions, runWork } from "@openshain/agent";
|
|
2
1
|
import {
|
|
3
2
|
type AnyEvent,
|
|
4
|
-
createRuntime,
|
|
5
3
|
type Event,
|
|
6
|
-
|
|
7
|
-
type RuntimeProviders,
|
|
4
|
+
pendingQuestions,
|
|
8
5
|
type ToolContent,
|
|
9
6
|
type Work,
|
|
10
|
-
type WorkId,
|
|
11
7
|
} from "@openshain/core";
|
|
12
|
-
import { describeInput, truncate } from "
|
|
13
|
-
import { failureLabel, rejectionLabel, statusLabel } from "
|
|
14
|
-
import { formatUsage, summarizeUsage } from "
|
|
15
|
-
|
|
16
|
-
export interface DriveOptions {
|
|
17
|
-
write: (line: string) => void;
|
|
18
|
-
/** Answers the model's questions. Without it, a question leaves the work waiting and the run ends. */
|
|
19
|
-
ask?: (question: string) => Promise<string>;
|
|
20
|
-
/** Stops the run. The work stays where it is and can be resumed. */
|
|
21
|
-
signal?: AbortSignal;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface RunOptions extends DriveOptions {
|
|
25
|
-
workspaceRoot: string;
|
|
26
|
-
providers: RuntimeProviders;
|
|
27
|
-
objective: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Creates a work for the request and drives it. Exit code 0 when the work completed. */
|
|
31
|
-
export async function run(options: RunOptions): Promise<number> {
|
|
32
|
-
const runtime = await createRuntime({
|
|
33
|
-
workspaceRoot: options.workspaceRoot,
|
|
34
|
-
providers: options.providers,
|
|
35
|
-
});
|
|
36
|
-
const work = await runtime.works.create({
|
|
37
|
-
objective: options.objective,
|
|
38
|
-
principal: runtime.config.principal.id,
|
|
39
|
-
profession: runtime.config.profession.id,
|
|
40
|
-
});
|
|
41
|
-
options.write(`${work.id} を開始`);
|
|
42
|
-
return drive(runtime, work.id, options);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Drives a work from its current state, printing one line per tool call, and closes with the report. */
|
|
46
|
-
export async function drive(
|
|
47
|
-
runtime: Runtime,
|
|
48
|
-
workId: WorkId,
|
|
49
|
-
options: DriveOptions,
|
|
50
|
-
): Promise<number> {
|
|
51
|
-
const names = new Map<string, string>();
|
|
52
|
-
const done = await runWork(runtime, workId, {
|
|
53
|
-
...(options.ask && { onInput: options.ask }),
|
|
54
|
-
...(options.signal && { signal: options.signal }),
|
|
55
|
-
onEvent: (event) => {
|
|
56
|
-
const line = progressLine(event, names);
|
|
57
|
-
if (line) options.write(line);
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
const events = await runtime.works.events(workId);
|
|
61
|
-
for (const line of report(done, events)) options.write(line);
|
|
62
|
-
return done.status === "completed" ? 0 : 1;
|
|
63
|
-
}
|
|
8
|
+
import { describeInput, truncate } from "./format.ts";
|
|
9
|
+
import { failureLabel, rejectionLabel, statusLabel } from "./labels.ts";
|
|
10
|
+
import { formatUsage, summarizeUsage } from "./usage.ts";
|
|
64
11
|
|
|
65
12
|
/** One line for a tool call, a rejection or a failure; nothing for the other events. `names` maps call ids to tool names. */
|
|
66
13
|
export function progressLine(event: AnyEvent, names: Map<string, string>): string | undefined {
|
|
@@ -126,9 +73,9 @@ export function nextActor(work: Work): string {
|
|
|
126
73
|
case "cancelled":
|
|
127
74
|
return "次に動く人はいません。";
|
|
128
75
|
case "waiting_input":
|
|
129
|
-
return `次は利用者の番です。openshain work resume ${work.id}
|
|
76
|
+
return `次は利用者の番です。openshain の会話で /work resume ${work.id} を実行し、続きを依頼すると質問に答えられます。`;
|
|
130
77
|
case "waiting_approval":
|
|
131
|
-
return "次は利用者の番です。承認が要ります。";
|
|
78
|
+
return "次は利用者の番です。承認が要ります。openshain の会話で /approvals を確かめ、/approve <id> か /reject <id> で決めます。";
|
|
132
79
|
case "failed":
|
|
133
80
|
return "次は利用者の番です。原因を修正して、もう一度依頼してください。";
|
|
134
81
|
default:
|
package/src/tui/app.tsx
CHANGED
|
@@ -18,7 +18,10 @@ const COLORS: Record<ScreenLine["kind"], string | undefined> = {
|
|
|
18
18
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
19
19
|
|
|
20
20
|
/** Rows that are not history: the header, the input box (three rows) and the status line. */
|
|
21
|
+
/** The rows around the conversation: the header, the input box (3) and the status line. */
|
|
21
22
|
const CHROME_ROWS = 5;
|
|
23
|
+
/** The approval palette is taller: its two lines of heading plus one row per choice. */
|
|
24
|
+
const APPROVAL_EXTRA_ROWS = 2;
|
|
22
25
|
|
|
23
26
|
function statusText(state: ControllerState, scrolled: number): string {
|
|
24
27
|
if (scrolled > 0)
|
|
@@ -70,9 +73,13 @@ export function App({ controller }: { controller: Controller }) {
|
|
|
70
73
|
}, [state.busy]);
|
|
71
74
|
|
|
72
75
|
// One row is left to the terminal: drawing exactly its height makes it scroll on every redraw.
|
|
73
|
-
const
|
|
76
|
+
const chromeRows =
|
|
77
|
+
(state.approval
|
|
78
|
+
? CHROME_ROWS + APPROVAL_EXTRA_ROWS + state.approval.choices.length
|
|
79
|
+
: CHROME_ROWS) + (state.queued.length > 0 && !state.approval ? 1 : 0);
|
|
80
|
+
const height = Math.max(chromeRows + 1, size.rows - 1);
|
|
74
81
|
const width = Math.max(20, size.columns);
|
|
75
|
-
const paneRows = height -
|
|
82
|
+
const paneRows = height - chromeRows;
|
|
76
83
|
const lines = useMemo(
|
|
77
84
|
() => screenLines(state.entries, width).map((line, row) => ({ ...line, row })),
|
|
78
85
|
[state.entries, width],
|
|
@@ -101,6 +108,17 @@ export function App({ controller }: { controller: Controller }) {
|
|
|
101
108
|
if (!controller.interrupt()) void controller.close();
|
|
102
109
|
return;
|
|
103
110
|
}
|
|
111
|
+
// While a call waits for approval, the keys pick a choice instead of typing.
|
|
112
|
+
if (state.approval) {
|
|
113
|
+
if (key.upArrow) return controller.moveApproval(-1);
|
|
114
|
+
if (key.downArrow) return controller.moveApproval(1);
|
|
115
|
+
if (key.return) return controller.decideApproval();
|
|
116
|
+
if (key.escape) return controller.decideApproval("reject");
|
|
117
|
+
const index = Number.parseInt(ch, 10) - 1;
|
|
118
|
+
const chosen = state.approval.choices[index];
|
|
119
|
+
if (chosen) return controller.decideApproval(chosen.key);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
104
122
|
if (key.pageUp) return setScroll((s) => Math.min(maxScroll, s + page));
|
|
105
123
|
if (key.pageDown) return setScroll((s) => Math.max(0, s - page));
|
|
106
124
|
// Up and down walk the lines sent before; below the newest one is what was being typed.
|
|
@@ -164,7 +182,9 @@ export function App({ controller }: { controller: Controller }) {
|
|
|
164
182
|
setCursor(at + [...ch].length);
|
|
165
183
|
});
|
|
166
184
|
|
|
185
|
+
const approval = state.approval;
|
|
167
186
|
const asking = state.question !== undefined;
|
|
187
|
+
const queued = state.queued;
|
|
168
188
|
const chars = [...input];
|
|
169
189
|
const at = Math.min(cursor, chars.length);
|
|
170
190
|
const before = chars.slice(0, at).join("");
|
|
@@ -186,11 +206,25 @@ export function App({ controller }: { controller: Controller }) {
|
|
|
186
206
|
<Box flexDirection="column" height={paneRows}>
|
|
187
207
|
{visible.map((line) => {
|
|
188
208
|
const color = COLORS[line.kind];
|
|
189
|
-
if (line.
|
|
209
|
+
if (line.spans) {
|
|
210
|
+
// Each piece is named by the column it starts at, which does not move.
|
|
211
|
+
let column = 0;
|
|
212
|
+
const pieces = line.spans.map((span) => {
|
|
213
|
+
const at = column;
|
|
214
|
+
column += span.text.length;
|
|
215
|
+
return { ...span, at };
|
|
216
|
+
});
|
|
190
217
|
return (
|
|
191
|
-
<Text key={line.row} wrap="truncate">
|
|
192
|
-
{
|
|
193
|
-
<Text
|
|
218
|
+
<Text key={line.row} wrap="truncate" {...(color && { color })}>
|
|
219
|
+
{pieces.map((s) => (
|
|
220
|
+
<Text
|
|
221
|
+
key={`${line.row}-${s.at}`}
|
|
222
|
+
{...(s.color && { color: s.color })}
|
|
223
|
+
{...(s.bold && { bold: true })}
|
|
224
|
+
{...(s.italic && { italic: true })}
|
|
225
|
+
{...(s.dim && { dimColor: true })}
|
|
226
|
+
{...(s.strikethrough && { strikethrough: true })}
|
|
227
|
+
>
|
|
194
228
|
{s.text}
|
|
195
229
|
</Text>
|
|
196
230
|
))}
|
|
@@ -209,14 +243,42 @@ export function App({ controller }: { controller: Controller }) {
|
|
|
209
243
|
);
|
|
210
244
|
})}
|
|
211
245
|
</Box>
|
|
212
|
-
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
246
|
+
{approval ? (
|
|
247
|
+
<Box borderStyle="round" borderColor="yellow" paddingX={1} flexDirection="column">
|
|
248
|
+
<Text color="yellow" wrap="truncate">
|
|
249
|
+
承認が要ります: {approval.title}
|
|
250
|
+
</Text>
|
|
251
|
+
<Text dimColor wrap="truncate">
|
|
252
|
+
規則 {approval.ruleId}
|
|
253
|
+
</Text>
|
|
254
|
+
{approval.choices.map((choice, index) => (
|
|
255
|
+
<Text
|
|
256
|
+
key={choice.key}
|
|
257
|
+
{...(index === approval.at && { color: "yellow" })}
|
|
258
|
+
wrap="truncate"
|
|
259
|
+
>
|
|
260
|
+
{index === approval.at ? "❯ " : " "}
|
|
261
|
+
{index + 1}. {choice.label}
|
|
262
|
+
</Text>
|
|
263
|
+
))}
|
|
264
|
+
</Box>
|
|
265
|
+
) : (
|
|
266
|
+
<Box borderStyle="round" borderColor={asking ? "magenta" : "gray"} paddingX={1}>
|
|
267
|
+
<Text color={asking ? "magenta" : "cyan"}>{asking ? "答え> " : "> "}</Text>
|
|
268
|
+
<Text>{before}</Text>
|
|
269
|
+
{under === "" ? <Text dimColor>▌</Text> : <Text inverse>{under}</Text>}
|
|
270
|
+
<Text>{after}</Text>
|
|
271
|
+
</Box>
|
|
272
|
+
)}
|
|
273
|
+
{queued.length > 0 && !approval ? (
|
|
274
|
+
<Text dimColor wrap="truncate">
|
|
275
|
+
順番待ち {queued.length} 件: {queued.join(" / ")}
|
|
276
|
+
</Text>
|
|
277
|
+
) : null}
|
|
218
278
|
<Text dimColor wrap="truncate">
|
|
219
|
-
{
|
|
279
|
+
{approval
|
|
280
|
+
? "↑ ↓ と Enter、または数字で選ぶ。Esc は拒否します。Ctrl-C は保留のまま止めます"
|
|
281
|
+
: bottom}
|
|
220
282
|
</Text>
|
|
221
283
|
</Box>
|
|
222
284
|
);
|
package/src/tui/banner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import cli from "../../package.json" with { type: "json" };
|
|
2
|
+
import type { Span } from "./markdown.ts";
|
|
2
3
|
|
|
3
4
|
/** The version of the openshain command, from its package.json. */
|
|
4
5
|
export const VERSION: string = cli.version;
|
|
@@ -19,15 +20,8 @@ const GRADIENT: readonly [readonly [number, number, number], readonly [number, n
|
|
|
19
20
|
[127, 136, 255],
|
|
20
21
|
];
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
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[] {
|
|
23
|
+
/** One colored piece per character, so the gradient runs across the row. */
|
|
24
|
+
export function logoSegments(row: string): Span[] {
|
|
31
25
|
const chars = [...row];
|
|
32
26
|
const last = Math.max(1, chars.length - 1);
|
|
33
27
|
return chars.map((text, at) => {
|
|
@@ -35,6 +29,6 @@ export function logoSegments(row: string): Segment[] {
|
|
|
35
29
|
const [from, to] = GRADIENT;
|
|
36
30
|
const channel = (k: 0 | 1 | 2) => Math.round(from[k] + (to[k] - from[k]) * t);
|
|
37
31
|
const hex = [channel(0), channel(1), channel(2)].map((v) => v.toString(16).padStart(2, "0"));
|
|
38
|
-
return { text, color: `#${hex.join("")}
|
|
32
|
+
return { text, color: `#${hex.join("")}` };
|
|
39
33
|
});
|
|
40
34
|
}
|