@visual-z/xeno 0.8.2 → 0.8.4
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/CHANGELOG.md +58 -0
- package/package.json +1 -1
- package/src/apps/review.ts +59 -59
- package/src/apps/shell.ts +20 -5
- package/src/apps/startup.ts +4 -4
- package/src/apps/ui/app.ts +143 -20
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +17 -2
- package/src/core/command-contract.ts +58 -16
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +124 -20
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +60 -0
- package/src/core/prompt-policy.ts +4 -2
- package/src/core/secrets.ts +188 -0
- package/src/core/stop-report.ts +25 -3
- package/src/core/tool-failure.ts +11 -3
- package/src/core/transcript.ts +3 -0
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +66 -18
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +264 -73
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +97 -27
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +1 -1
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/tui/ui/branch-panel.ts +47 -0
- package/src/tui/ui/composer.ts +27 -28
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
- package/src/tui/ui/prompt-history.ts +60 -0
- package/src/tui/ui/screen.ts +34 -3
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-session vault behind `[[xeno-secret-N]]` placeholders (S.9).
|
|
3
|
+
*
|
|
4
|
+
* A separate append-only file, never a log event: projections, handoffs,
|
|
5
|
+
* exports and history pages read the log, so a value kept here cannot reach a
|
|
6
|
+
* model request or a shared transcript. 0600 like every session file. It is
|
|
7
|
+
* read back at start so a placeholder written by an earlier run still expands
|
|
8
|
+
* after a restart.
|
|
9
|
+
*/
|
|
10
|
+
import { copyFile, readFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { durableAppend } from "./atomic.ts";
|
|
13
|
+
import type { SecretEntry } from "../core/secrets.ts";
|
|
14
|
+
|
|
15
|
+
export const SECRETS_FILE = "secrets.jsonl";
|
|
16
|
+
|
|
17
|
+
export async function readVault(dir: string): Promise<SecretEntry[]> {
|
|
18
|
+
let text: string;
|
|
19
|
+
try { text = await readFile(join(dir, SECRETS_FILE), "utf8"); }
|
|
20
|
+
catch (error) { if ((error as { code?: string }).code === "ENOENT") return []; throw error; }
|
|
21
|
+
const entries: SecretEntry[] = [];
|
|
22
|
+
for (const line of text.split("\n")) {
|
|
23
|
+
if (!line) continue;
|
|
24
|
+
// A torn final line from a crash loses that one entry; its placeholder then
|
|
25
|
+
// stays literal rather than expanding to something else.
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(line) as Partial<SecretEntry>;
|
|
28
|
+
if (Number.isSafeInteger(value.n) && typeof value.value === "string" && typeof value.rule === "string" &&
|
|
29
|
+
!entries.some(entry => entry.n === value.n)) entries.push({ n: value.n!, value: value.value, rule: value.rule });
|
|
30
|
+
} catch { /* see above */ }
|
|
31
|
+
}
|
|
32
|
+
return entries;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function appendVault(dir: string, entries: readonly SecretEntry[]): Promise<void> {
|
|
36
|
+
if (entries.length === 0) return;
|
|
37
|
+
await durableAppend(join(dir, SECRETS_FILE), entries.map(entry => JSON.stringify(entry) + "\n").join(""));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A fork's log carries the parent's placeholders, so it needs their values. */
|
|
41
|
+
export async function copyVault(from: string, to: string): Promise<void> {
|
|
42
|
+
try { await copyFile(join(from, SECRETS_FILE), join(to, SECRETS_FILE)); }
|
|
43
|
+
catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
|
|
44
|
+
}
|
package/src/store/sessions.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
readCursor,
|
|
36
36
|
readCursorAt,
|
|
37
37
|
readNode,
|
|
38
|
+
forkedAt,
|
|
38
39
|
writeCursor,
|
|
39
40
|
setParentSession,
|
|
40
41
|
type Manifest,
|
|
@@ -44,6 +45,7 @@ import { replayContext } from "./replay.ts";
|
|
|
44
45
|
import { recoverCut } from "./recovery.ts";
|
|
45
46
|
import { planSessionEntry, type SessionEntryFlags } from "../core/session-entry.ts";
|
|
46
47
|
import { assertNever } from "../core/exhaustive.ts";
|
|
48
|
+
import type { LogEvent } from "../core/events.ts";
|
|
47
49
|
import { resolveHistoryPosition } from "./session-position.ts";
|
|
48
50
|
import { importSessionArchive } from "./session-archive.ts";
|
|
49
51
|
|
|
@@ -86,6 +88,10 @@ export type ListedSession =
|
|
|
86
88
|
workspace?: string;
|
|
87
89
|
parent_session?: string;
|
|
88
90
|
text?: string;
|
|
91
|
+
/** Details only: whether any node holds a user event (fix-skip-empty-sessions). */
|
|
92
|
+
conversation?: boolean;
|
|
93
|
+
/** Details only, forks only: when the copy was made (fix-fork-start-time). */
|
|
94
|
+
forked_at?: string;
|
|
89
95
|
}
|
|
90
96
|
| {
|
|
91
97
|
ok: false;
|
|
@@ -370,8 +376,7 @@ export async function resolveLogDir(
|
|
|
370
376
|
return { logDir: created.path, created: true };
|
|
371
377
|
}
|
|
372
378
|
case "continue": {
|
|
373
|
-
const
|
|
374
|
-
const newest = sessions.find((s) => s.ok);
|
|
379
|
+
const newest = await newestConversation(workspace, opts.sessionDir);
|
|
375
380
|
if (newest !== undefined) return { logDir: newest.path, created: false };
|
|
376
381
|
break;
|
|
377
382
|
}
|
|
@@ -399,7 +404,8 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
399
404
|
if ((err as { code?: string }).code === "ENOENT") {
|
|
400
405
|
return {
|
|
401
406
|
ok: true, id, path,
|
|
402
|
-
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
407
|
+
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
408
|
+
...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
|
|
403
409
|
};
|
|
404
410
|
}
|
|
405
411
|
return { ok: false, id, path, error: `${path} could not be read: ${(err as Error).message}` };
|
|
@@ -414,7 +420,8 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
414
420
|
if (cursor === undefined) {
|
|
415
421
|
return {
|
|
416
422
|
ok: true, id, path,
|
|
417
|
-
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
423
|
+
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
424
|
+
...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
|
|
418
425
|
};
|
|
419
426
|
}
|
|
420
427
|
|
|
@@ -423,15 +430,18 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
423
430
|
// bad tree is the same shape as a list that throws for one bad file.
|
|
424
431
|
const events = (await readNode(path, cursor.node)).events.length;
|
|
425
432
|
const text: string[] = [];
|
|
433
|
+
let conversation = false;
|
|
426
434
|
if (details) for (const node of await listNodes(path)) {
|
|
427
435
|
if (details.signal?.aborted) break;
|
|
428
436
|
const read = await readNode(path, node);
|
|
429
437
|
if (read.corrupt.length) throw new Error(`${path} has corrupt events`);
|
|
430
438
|
for (const event of read.events) {
|
|
439
|
+
if (userActivity(event)) conversation = true;
|
|
431
440
|
if (event.t === "user") text.push(event.text);
|
|
432
441
|
else if (event.t === "assistant") for (const block of event.blocks) if (block.type === "text") text.push(block.text);
|
|
433
442
|
}
|
|
434
443
|
}
|
|
444
|
+
const forked = details ? await forkedAt(path) : null;
|
|
435
445
|
return {
|
|
436
446
|
ok: true,
|
|
437
447
|
id,
|
|
@@ -441,11 +451,44 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
441
451
|
node: cursor.node,
|
|
442
452
|
events,
|
|
443
453
|
updated_at: cursor.updated_at,
|
|
444
|
-
...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"),
|
|
445
|
-
...(manifest.parent_session ? { parent_session: manifest.parent_session } : {})
|
|
454
|
+
...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"), conversation,
|
|
455
|
+
...(manifest.parent_session ? { parent_session: manifest.parent_session } : {}),
|
|
456
|
+
...(forked === null ? {} : { forked_at: forked }) } : {}),
|
|
446
457
|
};
|
|
447
458
|
}
|
|
448
459
|
|
|
460
|
+
/**
|
|
461
|
+
* A session the user opened and left without a word still has a header,
|
|
462
|
+
* cursor and manifest: Session.start writes them before the first message.
|
|
463
|
+
* Pi avoids such files by not persisting until a message exists; xeno keeps
|
|
464
|
+
* its write order and filters on read instead, like Codex's resume picker,
|
|
465
|
+
* so -c and the pickers land on a conversation (fix-skip-empty-sessions).
|
|
466
|
+
* A shell command the user ran counts: it is their activity, not setup.
|
|
467
|
+
*/
|
|
468
|
+
function userActivity(event: LogEvent): boolean {
|
|
469
|
+
return event.t === "user" || event.t === "user_bash";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Stops at the first node holding user activity, so a real session costs one
|
|
474
|
+
* node read. An unreadable node counts as activity: hiding a session we
|
|
475
|
+
* cannot judge would silently skip it, while opening it reports the error.
|
|
476
|
+
*/
|
|
477
|
+
export async function hasConversation(path: string): Promise<boolean> {
|
|
478
|
+
for (const node of await listNodes(path)) {
|
|
479
|
+
try { if ((await readNode(path, node)).events.some(userActivity)) return true; }
|
|
480
|
+
catch { return true; }
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** What -c continues: the newest readable session the user actually spoke in. */
|
|
486
|
+
export async function newestConversation(workspace: string, sessionDir?: string): Promise<Extract<ListedSession, { ok: true }> | undefined> {
|
|
487
|
+
const { sessions } = await listSessions(workspace, sessionDir, Infinity);
|
|
488
|
+
for (const session of sessions) if (session.ok && await hasConversation(session.path)) return session;
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
449
492
|
function byRecordedTime(a: ListedSession, b: ListedSession): number {
|
|
450
493
|
const ta = a.ok ? a.updated_at : null;
|
|
451
494
|
const tb = b.ok ? b.updated_at : null;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rows under the input box, one per unresolved branch (feat-branch-panel).
|
|
3
|
+
* View-only: the controller owns which branches exist and what keys do (D20).
|
|
4
|
+
* Claude Code's precedent: background subagents sit below the prompt, ↑/↓
|
|
5
|
+
* select, Enter opens, x stops, Esc returns to the prompt.
|
|
6
|
+
*/
|
|
7
|
+
import type { Line } from "./status.ts";
|
|
8
|
+
import type { Seg } from "./edge.ts";
|
|
9
|
+
|
|
10
|
+
export type BranchRowState = "running" | "done" | "stopped" | "failed" | "cancelled" | "unknown";
|
|
11
|
+
export interface BranchRow { readonly node: number; readonly purpose: string; readonly state: BranchRowState }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rows shown before the rest fold into "+N more". Assumed, not measured: three
|
|
15
|
+
* keeps the transcript in view on a 24-row terminal; more branches than that
|
|
16
|
+
* at once is rare because branch capacity is itself small.
|
|
17
|
+
*/
|
|
18
|
+
export const BRANCH_ROWS = 3;
|
|
19
|
+
|
|
20
|
+
const MARK: Record<BranchRowState, Seg> = {
|
|
21
|
+
running: { text: "●", role: "accent" },
|
|
22
|
+
done: { text: "✓", role: "success" },
|
|
23
|
+
stopped: { text: "◆", role: "warning" },
|
|
24
|
+
failed: { text: "✗", role: "error" },
|
|
25
|
+
cancelled: { text: "■", role: "muted" },
|
|
26
|
+
unknown: { text: "?", role: "muted" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** `selected` is the focused row, or undefined while the prompt has focus. The window keeps it visible. */
|
|
30
|
+
export function branchPanelLines(rows: readonly BranchRow[], selected: number | undefined): Line[] {
|
|
31
|
+
if (rows.length === 0) return [];
|
|
32
|
+
const start = selected === undefined ? 0 : Math.max(0, Math.min(selected - BRANCH_ROWS + 1, rows.length - BRANCH_ROWS));
|
|
33
|
+
const shown = rows.slice(start, start + BRANCH_ROWS);
|
|
34
|
+
const lines: Line[] = shown.map((row, k) => {
|
|
35
|
+
const focused = start + k === selected;
|
|
36
|
+
return [
|
|
37
|
+
{ text: focused ? "› " : " ", role: "accent", bold: true },
|
|
38
|
+
MARK[row.state],
|
|
39
|
+
{ text: ` ${row.purpose}`, role: focused ? "text" : "muted", bold: focused },
|
|
40
|
+
{ text: ` · ${row.state}`, role: row.state === "failed" ? "error" : "muted" },
|
|
41
|
+
...(focused ? [{ text: row.state === "running" ? " enter open · x stop · esc back" : " enter open · x clear · esc back", role: "muted" as const }] : []),
|
|
42
|
+
];
|
|
43
|
+
});
|
|
44
|
+
const hidden = rows.length - shown.length;
|
|
45
|
+
if (hidden > 0) lines.push([{ text: ` +${hidden} more`, role: "muted" }]);
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
package/src/tui/ui/composer.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { Canvas } from "./canvas.ts";
|
|
|
14
14
|
import type { UiColors } from "./colors.ts";
|
|
15
15
|
import { draftRows, type DraftRows } from "./draft.ts";
|
|
16
16
|
import type { ListItem } from "./list-model.ts";
|
|
17
|
+
import { emptyHistory, historyPosition, projectHistory, rememberPrompt, stepHistory, type PromptHistory } from "./prompt-history.ts";
|
|
17
18
|
|
|
18
19
|
export interface Completion {
|
|
19
20
|
readonly kind: "command" | "argument" | "path";
|
|
@@ -48,9 +49,7 @@ export class Composer {
|
|
|
48
49
|
private readonly prompt: Canvas;
|
|
49
50
|
private completion: Completion | undefined;
|
|
50
51
|
private completionIndex = 0;
|
|
51
|
-
private history:
|
|
52
|
-
private historyAt = -1;
|
|
53
|
-
private historyDraft = "";
|
|
52
|
+
private history: PromptHistory = emptyHistory;
|
|
54
53
|
private maxLines = MAX_LINES;
|
|
55
54
|
private shape: DraftRows = { rows: 1, above: 0, below: 0 };
|
|
56
55
|
onChange: ((text: string) => void) | undefined;
|
|
@@ -93,7 +92,9 @@ export class Composer {
|
|
|
93
92
|
get completionView(): { readonly kind: Completion["kind"]; readonly items: readonly ListItem[]; readonly selected: number } | undefined {
|
|
94
93
|
return this.completion ? { kind: this.completion.kind, items: this.completion.items, selected: this.completionIndex } : undefined;
|
|
95
94
|
}
|
|
96
|
-
get historyEntries(): readonly string[] { return this.history; }
|
|
95
|
+
get historyEntries(): readonly string[] { return this.history.entries; }
|
|
96
|
+
/** Which recalled entry is showing, for the box edge; undefined while editing the draft. */
|
|
97
|
+
get historyPosition(): { readonly index: number; readonly total: number } | undefined { return historyPosition(this.history); }
|
|
97
98
|
get rows(): number { return Math.max(1, this.area.height); }
|
|
98
99
|
/** Wrapped rows of the draft hidden above / below the box (0 when it all fits). */
|
|
99
100
|
get overflow(): { readonly above: number; readonly below: number } { return { above: this.shape.above, below: this.shape.below }; }
|
|
@@ -116,36 +117,34 @@ export class Composer {
|
|
|
116
117
|
insert(text: string): void { this.draftRevision++; this.area.insertText(text); }
|
|
117
118
|
removeAtom(label: string): void { this.draftRevision++; this.area.replaceText(this.text.split(label).join("")); }
|
|
118
119
|
resetDraft(project: (text: string) => string): void {
|
|
119
|
-
this.history = this.history
|
|
120
|
-
this.
|
|
120
|
+
this.history = projectHistory(this.history, project);
|
|
121
|
+
this.area.clearKilledText(); this.setText("");
|
|
121
122
|
}
|
|
122
123
|
focus(): void { this.area.focus(); }
|
|
123
124
|
|
|
124
|
-
remember(entry: string): void {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
remember(entry: string): void { this.history = rememberPrompt(this.history, entry); }
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Up/Down recall history only from the first/last *visual* row, so a
|
|
129
|
+
* wrapped paragraph is navigable like a multi-line one (Claude Code's
|
|
130
|
+
* documented rule). Cursor offsets here are display cells, not string
|
|
131
|
+
* indices, so they are only compared with each other.
|
|
132
|
+
*/
|
|
133
|
+
/** The cursor is on the draft's last visual row: Down has nowhere to move within it. */
|
|
134
|
+
get onLastRow(): boolean {
|
|
135
|
+
const view = this.area.editorView;
|
|
136
|
+
return !this.area.plainText.slice(this.area.textCursor).includes("\n") && view.getVisualEOL().offset === view.getEOL().offset;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
/** Up/Down walk history only when the cursor is on the first/last line, so multi-line drafts stay editable. */
|
|
132
139
|
historyStep(direction: -1 | 1): boolean {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (this.historyAt === -1) this.historyDraft = text;
|
|
142
|
-
this.historyAt++;
|
|
143
|
-
this.setText(this.history[this.historyAt]!);
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
if (this.historyAt < 0) return false;
|
|
147
|
-
this.historyAt--;
|
|
148
|
-
this.setText(this.historyAt < 0 ? this.historyDraft : this.history[this.historyAt]!);
|
|
140
|
+
const view = this.area.editorView;
|
|
141
|
+
if (direction < 0 && view.getVisualSOL().offset !== 0) return false;
|
|
142
|
+
if (direction > 0 && !this.onLastRow) return false;
|
|
143
|
+
const step = stepHistory(this.history, direction, this.area.plainText);
|
|
144
|
+
// Up with nothing older is consumed (the cursor is already on the top row); Down from the draft is not.
|
|
145
|
+
if (!step) return direction < 0;
|
|
146
|
+
this.history = step.history;
|
|
147
|
+
this.setText(step.text);
|
|
149
148
|
return true;
|
|
150
149
|
}
|
|
151
150
|
|
package/src/tui/ui/image-size.ts
CHANGED
|
@@ -26,9 +26,13 @@ export function imageCells(pixels: { readonly width: number; readonly height: nu
|
|
|
26
26
|
return { cols: Math.max(1, Math.min(capCols, cols)), rows: Math.max(1, Math.min(capRows, rows)) };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Rows for pictures in the transcript: half the terminal, 4–20 (feat-tui-image-full-size).
|
|
31
|
+
* A quarter left a screenshot's text unreadable; 20 is Grok's ceiling, past which one picture
|
|
32
|
+
* pushes the conversation off screen. Assumed, not measured. Full size is a click away.
|
|
33
|
+
*/
|
|
30
34
|
export function transcriptImageRows(terminalRows: number): number {
|
|
31
|
-
return Math.max(4, Math.min(
|
|
35
|
+
return Math.max(4, Math.min(20, Math.floor(terminalRows / 2)));
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** Rows for the attachment strip above the input box: a sixth of the terminal, 3–6. Assumed. */
|
package/src/tui/ui/items.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
export type ToolStatus = "pending" | "ok" | "error";
|
|
9
9
|
|
|
10
10
|
/** A picture the view may draw: its blob address, name and pixel size (from the log's ImageRef). */
|
|
11
|
-
export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number
|
|
11
|
+
export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number;
|
|
12
|
+
/** The blob a click opens when it differs from `sha256`: what was attached, before any reduction for the model. */
|
|
13
|
+
readonly full?: string }
|
|
12
14
|
|
|
13
15
|
export type ViewItem =
|
|
14
16
|
| { readonly kind: "user"; readonly id: string; readonly rev: string; readonly text: string; readonly images?: readonly ImageView[]; readonly interrupted?: boolean }
|
package/src/tui/ui/pictures.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* pictures are off, or the terminal can only draw block characters, the
|
|
9
9
|
* row is a single muted line: `▣ shot.png · 1280×720 +2 more`.
|
|
10
10
|
*
|
|
11
|
+
* A click on a drawn picture asks the source to open its full-size original
|
|
12
|
+
* (docs/tasks/feat-tui-image-full-size.md): a thumbnail cannot show a
|
|
13
|
+
* screenshot's text, the system viewer can.
|
|
14
|
+
*
|
|
11
15
|
* The view never reads a file: the app's source loads and verifies bytes.
|
|
12
16
|
*/
|
|
13
17
|
import { BoxRenderable, ImageRenderable, type CliRenderer, type ImageRenderProtocol, type NativeImage } from "@opentui/core";
|
|
@@ -28,6 +32,8 @@ export interface PictureSource {
|
|
|
28
32
|
readonly reason?: string;
|
|
29
33
|
/** The decoded picture for a blob, shared by every node that shows it, or its preparation failure. */
|
|
30
34
|
load(sha256: string): Promise<PictureLoad>;
|
|
35
|
+
/** Show a blob at full size outside the terminal; absent where nothing can (tests, print mode). */
|
|
36
|
+
open?(sha256: string): void;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export type PictureLoad = { readonly kind: "ready"; readonly image: NativeImage } |
|
|
@@ -72,7 +78,7 @@ export class Pictures extends BoxRenderable {
|
|
|
72
78
|
const size = imageCells(image, Math.min(each, source.widthCells), options.maxRows, source.cell);
|
|
73
79
|
const picture = new ImageRenderable(ctx, { id: `${id}-${image.sha256.slice(0, 8)}-${serial++}`, width: size.cols, height: size.rows,
|
|
74
80
|
fit: "fit", protocol: source.protocol, flexShrink: 0,
|
|
75
|
-
onLoad: () => { states[index] = "decoded"; if (!caption.isDestroyed) paintCaption(); },
|
|
81
|
+
onLoad: () => { states[index] = source.open ? "click to open" : "decoded"; if (!caption.isDestroyed) paintCaption(); },
|
|
76
82
|
onError: () => { states[index] = "preview failed: decode"; picture.visible = false; if (!caption.isDestroyed) paintCaption(); } });
|
|
77
83
|
void source.load(image.sha256).then(result => {
|
|
78
84
|
if (picture.isDestroyed) return;
|
|
@@ -82,6 +88,13 @@ export class Pictures extends BoxRenderable {
|
|
|
82
88
|
if (picture.isDestroyed) return;
|
|
83
89
|
picture.visible = false; states[index] = "preview failed: decode"; paintCaption();
|
|
84
90
|
});
|
|
91
|
+
const open = source.open;
|
|
92
|
+
if (open) {
|
|
93
|
+
// Same rule as a tool card's head: press and release on one cell is a click; anything else is a selection.
|
|
94
|
+
let down: { x: number; y: number } | undefined;
|
|
95
|
+
picture.onMouseDown = event => { down = { x: event.x, y: event.y }; };
|
|
96
|
+
picture.onMouseUp = event => { if (down && down.x === event.x && down.y === event.y && picture.image) open(image.full ?? image.sha256); down = undefined; };
|
|
97
|
+
}
|
|
85
98
|
pictures.push(picture);
|
|
86
99
|
row.add(picture);
|
|
87
100
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Up/Down prompt recall as a pure value, so its one promise -- navigating
|
|
3
|
+
* never loses text -- is a property test rather than a hope.
|
|
4
|
+
*
|
|
5
|
+
* Readline's precedent: an edit to a recalled line is kept for that line
|
|
6
|
+
* while the user walks elsewhere, and dropped only when something is
|
|
7
|
+
* submitted. Without it, walking Down from an edited entry restores the
|
|
8
|
+
* stashed draft and the edit is gone (docs/tasks/fix-composer-history.md).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Entries kept, newest first. Assumed, not measured: the composer kept 200
|
|
13
|
+
* before this module existed and nothing has asked for more. Raising it
|
|
14
|
+
* only costs memory; lowering it drops the oldest prompts from recall.
|
|
15
|
+
*/
|
|
16
|
+
export const HISTORY_LIMIT = 200;
|
|
17
|
+
|
|
18
|
+
export type PromptHistory = {
|
|
19
|
+
/** Submitted prompts, newest first. Navigation never changes them. */
|
|
20
|
+
readonly entries: readonly string[];
|
|
21
|
+
/** -1 is the draft; 0.. index entries. */
|
|
22
|
+
readonly at: number;
|
|
23
|
+
/** Unsubmitted text per position (-1 included), so leaving a position keeps what was typed there. */
|
|
24
|
+
readonly edits: ReadonlyMap<number, string>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const emptyHistory: PromptHistory = { entries: [], at: -1, edits: new Map() };
|
|
28
|
+
|
|
29
|
+
/** A submit ends the walk: edits are discarded and the draft starts empty. */
|
|
30
|
+
export function rememberPrompt(history: PromptHistory, entry: string, limit = HISTORY_LIMIT): PromptHistory {
|
|
31
|
+
if (!entry.trim()) return { ...history, at: -1, edits: new Map() };
|
|
32
|
+
// A repeat of the newest entry records once, so Up reaches the previous distinct prompt.
|
|
33
|
+
const entries = history.entries[0] === entry ? history.entries : [entry, ...history.entries].slice(0, limit);
|
|
34
|
+
return { entries, at: -1, edits: new Map() };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Older (-1) or newer (+1). `current` is the text at the present position.
|
|
39
|
+
* Null means there is nowhere to go; the caller decides whether that
|
|
40
|
+
* consumes the key.
|
|
41
|
+
*/
|
|
42
|
+
export function stepHistory(history: PromptHistory, direction: -1 | 1, current: string): { history: PromptHistory; text: string } | null {
|
|
43
|
+
const at = history.at - direction;
|
|
44
|
+
if (at < -1 || at >= history.entries.length) return null;
|
|
45
|
+
const edits = new Map(history.edits);
|
|
46
|
+
// An unchanged entry needs no edit; the draft is always kept, even when empty.
|
|
47
|
+
if (history.at === -1 || current !== history.entries[history.at]) edits.set(history.at, current);
|
|
48
|
+
else edits.delete(history.at);
|
|
49
|
+
return { history: { entries: history.entries, at, edits }, text: edits.get(at) ?? history.entries[at] ?? "" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 1-based position for the box edge, or undefined while editing the draft. */
|
|
53
|
+
export function historyPosition(history: PromptHistory): { index: number; total: number } | undefined {
|
|
54
|
+
return history.at < 0 ? undefined : { index: history.at + 1, total: history.entries.length };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Rewrites entries (session change: labels that no longer resolve) and starts a fresh walk. */
|
|
58
|
+
export function projectHistory(history: PromptHistory, project: (text: string) => string): PromptHistory {
|
|
59
|
+
return { entries: history.entries.map(project).filter(Boolean), at: -1, edits: new Map() };
|
|
60
|
+
}
|
package/src/tui/ui/screen.ts
CHANGED
|
@@ -85,6 +85,11 @@ export class Screen {
|
|
|
85
85
|
private readonly body: BoxRenderable;
|
|
86
86
|
private readonly topEdge: Canvas;
|
|
87
87
|
private readonly bottomEdge: Canvas;
|
|
88
|
+
/** Branch rows under the box (feat-branch-panel); hidden when there are none, so the box stays on the last row. */
|
|
89
|
+
private readonly below: Canvas;
|
|
90
|
+
private branchLines: readonly Line[] = [];
|
|
91
|
+
/** `branch N · purpose` while the transcript shows a branch instead of the main session. */
|
|
92
|
+
private viewingLabel: string | undefined;
|
|
88
93
|
private readonly sidebar: BoxRenderable;
|
|
89
94
|
private readonly sidebarRows: Canvas;
|
|
90
95
|
private readonly middle: BoxRenderable;
|
|
@@ -153,7 +158,8 @@ export class Screen {
|
|
|
153
158
|
this.body.add(slot); this.body.add(this.composer.row);
|
|
154
159
|
this.bottomEdge = new Canvas(renderer, { id: "dock-bottom", height: 1, flexShrink: 0, colors });
|
|
155
160
|
this.dock.add(this.topEdge); this.dock.add(this.body); this.dock.add(this.bottomEdge);
|
|
156
|
-
|
|
161
|
+
this.below = new Canvas(renderer, { id: "below-dock", visible: false, height: 1, flexShrink: 0, marginLeft: 1, marginRight: 1, colors });
|
|
162
|
+
main.add(this.greeting); main.add(area); main.add(this.above); main.add(this.dock); main.add(this.below);
|
|
157
163
|
this.sidebar = new BoxRenderable(renderer, { id: "sidebar", flexShrink: 0, border: ["left"], borderColor: colors.line, paddingLeft: 1, paddingRight: 1, paddingTop: 1, flexDirection: "column", overflow: "hidden", backgroundColor: colors.background });
|
|
158
164
|
this.sidebarRows = new Canvas(renderer, { id: "sidebar-rows", flexGrow: 1, colors });
|
|
159
165
|
this.sidebarRows.onSizeChange = () => this.paintStatus();
|
|
@@ -208,7 +214,7 @@ export class Screen {
|
|
|
208
214
|
setColors(colors: UiColors): void {
|
|
209
215
|
this.colors = colors;
|
|
210
216
|
this.renderer.setBackgroundColor(colors.background);
|
|
211
|
-
for (const canvas of [this.topbar, this.greeting, this.pill, this.completion, this.queueRows, this.widgetRows, this.topEdge, this.bottomEdge, this.sidebarRows]) canvas.setColors(colors);
|
|
217
|
+
for (const canvas of [this.topbar, this.greeting, this.pill, this.completion, this.queueRows, this.widgetRows, this.topEdge, this.bottomEdge, this.below, this.sidebarRows]) canvas.setColors(colors);
|
|
212
218
|
this.sidebar.borderColor = colors.line;
|
|
213
219
|
this.sidebar.backgroundColor = colors.background;
|
|
214
220
|
this.messages.setColors(colors);
|
|
@@ -302,6 +308,7 @@ export class Screen {
|
|
|
302
308
|
this.paintStatus();
|
|
303
309
|
this.paintEdges();
|
|
304
310
|
this.paintAbove();
|
|
311
|
+
this.paintBelow();
|
|
305
312
|
this.paintGreeting();
|
|
306
313
|
this.renderer.requestRender();
|
|
307
314
|
}
|
|
@@ -313,7 +320,7 @@ export class Screen {
|
|
|
313
320
|
|
|
314
321
|
private aboveRows(): number {
|
|
315
322
|
return (this.completion.visible ? this.completion.height as number : 0) + (this.queueRows.visible ? this.queueRows.height as number : 0) + (this.widgetRows.visible ? this.widgetRows.height as number : 0)
|
|
316
|
-
+ (this.attachments.visible ? this.attachments.height : 0);
|
|
323
|
+
+ (this.attachments.visible ? this.attachments.height : 0) + (this.below.visible ? this.below.height as number : 0);
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
// ------------------------------------------------------------------ status
|
|
@@ -398,6 +405,13 @@ export class Screen {
|
|
|
398
405
|
if (below && !this.toastText) right.push({ segs: [{ text: `↓ ${below} more`, role: "muted" }], priority: 2 });
|
|
399
406
|
if (!above && this.status?.activity) left = [idleActivityLabel(this.status.activity, this.lastTurnMs)];
|
|
400
407
|
}
|
|
408
|
+
if (this.viewingLabel && !dialog) {
|
|
409
|
+
left = [{ segs: [{ text: this.viewingLabel, role: "accent", bold: true }], priority: 0, min: 8, shrink: 0 }];
|
|
410
|
+
right.push({ segs: [{ text: "esc back", role: "muted" }], priority: 4 });
|
|
411
|
+
}
|
|
412
|
+
// Which recalled prompt is showing (fix-composer-history). Leads the edge: it describes the text being edited.
|
|
413
|
+
const recalled = this.composer.historyPosition;
|
|
414
|
+
if (recalled) left = [{ segs: [{ text: `history ${recalled.index}/${recalled.total}`, role: "accent" }], priority: 0, min: 1 }, ...left];
|
|
401
415
|
this.topEdge.setRows([edgeLine({ width, corners: ["╭", "╮"], role, left, right })]);
|
|
402
416
|
const bottomLeft: Label[] = [...(this.status ? modeLabels(this.status) : []), ...(d.model ? [
|
|
403
417
|
{ segs: [{ text: d.model, role: "text" as const, bold: true }], priority: 1, min: 8 },
|
|
@@ -446,6 +460,23 @@ export class Screen {
|
|
|
446
460
|
// ------------------------------------------------------------ above the box
|
|
447
461
|
setQueue(lines: readonly string[]): void { this.queue = lines; this.paintAbove(); }
|
|
448
462
|
|
|
463
|
+
// ------------------------------------------------------------ below the box
|
|
464
|
+
/** Rows from branchPanelLines; an empty list hides the region. */
|
|
465
|
+
setBranches(lines: readonly Line[]): void {
|
|
466
|
+
const shown = this.below.visible ? this.below.height as number : 0;
|
|
467
|
+
this.branchLines = lines;
|
|
468
|
+
this.paintBelow();
|
|
469
|
+
if ((lines.length || 0) !== shown) this.layout();
|
|
470
|
+
}
|
|
471
|
+
/** The transcript shows a branch: its label leads the top edge (undefined: the main session). */
|
|
472
|
+
setViewing(label: string | undefined): void { this.viewingLabel = label; this.paintEdges(); }
|
|
473
|
+
private paintBelow(): void {
|
|
474
|
+
const width = Math.max(1, this.renderer.width - (this.shape.tier === "sidebar" ? this.shape.sidebarWidth : 0) - 2);
|
|
475
|
+
this.below.visible = this.branchLines.length > 0;
|
|
476
|
+
if (this.below.visible) { this.below.height = this.branchLines.length; this.below.setRows(this.branchLines.map(row => clipSegs(row, width))); }
|
|
477
|
+
this.renderer.requestRender();
|
|
478
|
+
}
|
|
479
|
+
|
|
449
480
|
/** How pictures are drawn; the transcript and the attachment strip both follow it (feat-tui-inline-images). */
|
|
450
481
|
setPictures(source: PictureSource): void {
|
|
451
482
|
this.picturesSource = source;
|