@adeildo/pi-look 5.9.2 → 5.10.1

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.
@@ -0,0 +1,217 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+
5
+ // While the model writes an edit or a write, pi shows the path, or the first lines of the file,
6
+ // which stopped changing long ago. These show the end instead: the lines arriving, with a cursor
7
+ // where the model is writing, and what it has written so far on the bottom rule.
8
+ import { GLYPH, isObject } from "@adeildo/pi-kit";
9
+ import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
10
+
11
+ import { tabs } from "./format.ts";
12
+
13
+ /** What the frame shows inside, and the counts it adds to the bottom rule. */
14
+ export interface Stream {
15
+ lines: string[];
16
+ progress: string[];
17
+ }
18
+
19
+ export interface StreamContext {
20
+ cwd: string;
21
+ expanded: boolean;
22
+ /** Shared by every render of one call, which keeps what was read from disk. */
23
+ state: { streamTarget?: Target };
24
+ }
25
+
26
+ type Streamer = (args: unknown, theme: Theme, context: StreamContext) => Stream | undefined;
27
+
28
+ /** How many of the last lines a write shows while it streams. */
29
+ const WRITE_TAIL = 8;
30
+ /** How many lines of the diff an edit shows while it streams. */
31
+ const EDIT_TAIL = 10;
32
+ const CURSOR = GLYPH.cursor;
33
+ /** Larger files are not read to count their lines. */
34
+ const COUNT_LIMIT = 4 * 1024 * 1024;
35
+
36
+ export const STREAMERS: Readonly<Record<string, Streamer>> = {
37
+ write: writeStream,
38
+ edit: editStream,
39
+ };
40
+
41
+ /** What a write is about to replace. */
42
+ interface Target {
43
+ path: string;
44
+ lines?: number;
45
+ exists: boolean;
46
+ }
47
+
48
+ export function writeStream(
49
+ args: unknown,
50
+ theme: Theme,
51
+ context: StreamContext,
52
+ ): Stream | undefined {
53
+ const path = pathOf(args);
54
+ const content = field(args, "content");
55
+ if (path === undefined || content === undefined) return undefined;
56
+
57
+ const target = targetOf(path, context);
58
+ const header = `${pathLine("write", path, theme)} ${theme.fg("muted", replaces(target))}`;
59
+ const lines = content.split("\n");
60
+ const shown = context.expanded ? lines : lines.slice(-WRITE_TAIL);
61
+ const first = lines.length - shown.length;
62
+ const gutter = String(lines.length).length;
63
+ const lang = getLanguageFromPath(path);
64
+ const painted = lang
65
+ ? highlightCode(tabs(shown.join("\n")), lang)
66
+ : shown.map((line) => theme.fg("toolOutput", tabs(line)));
67
+ const body = shown.map(
68
+ (_, index) =>
69
+ `${theme.fg("dim", String(first + index + 1).padStart(gutter))} ${painted[index] ?? ""}`,
70
+ );
71
+ return {
72
+ lines: [header, ...above(first, gutter, theme), ...withCursor(body, theme)],
73
+ progress: [`${lines.length} line${lines.length === 1 ? "" : "s"}`, size(content)],
74
+ };
75
+ }
76
+
77
+ export function editStream(
78
+ args: unknown,
79
+ theme: Theme,
80
+ context: StreamContext,
81
+ ): Stream | undefined {
82
+ const path = pathOf(args);
83
+ const edits = editsOf(args);
84
+ if (path === undefined || edits.length === 0) return undefined;
85
+
86
+ let removed = 0;
87
+ let added = 0;
88
+ const diff: string[] = [];
89
+ for (const [index, edit] of edits.entries()) {
90
+ if (index > 0) diff.push(theme.fg("dim", GLYPH.ellipsisAcross));
91
+ // Only the last edit can still be arriving, so only the others have a known end.
92
+ const lines = editLines(edit, index < edits.length - 1);
93
+ for (const line of lines.before) diff.push(theme.fg("toolDiffContext", ` ${tabs(line)}`));
94
+ for (const line of lines.out) diff.push(theme.fg("toolDiffRemoved", `-${tabs(line)}`));
95
+ for (const line of lines.in) diff.push(theme.fg("toolDiffAdded", `+${tabs(line)}`));
96
+ for (const line of lines.after) diff.push(theme.fg("toolDiffContext", ` ${tabs(line)}`));
97
+ removed += lines.out.length;
98
+ added += lines.in.length;
99
+ }
100
+ const shown = context.expanded ? diff : diff.slice(-EDIT_TAIL);
101
+ const hidden = diff.length - shown.length;
102
+ return {
103
+ lines: [pathLine("edit", path, theme), ...above(hidden, 0, theme), ...withCursor(shown, theme)],
104
+ progress: [...(edits.length > 1 ? [`edit ${edits.length}`] : []), `-${removed} +${added}`],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * The lines an edit takes out and puts in. The model repeats the lines around the change in both
110
+ * texts, so the lines both start with, and the lines both end with once the edit is complete, are
111
+ * context. A line the new text has not repeated yet counts as taken out until it does.
112
+ */
113
+ export function editLines(
114
+ edit: PartialEdit,
115
+ complete: boolean,
116
+ ): { before: string[]; out: string[]; in: string[]; after: string[] } {
117
+ const old = edit.oldText?.split("\n") ?? [];
118
+ const next = edit.newText?.split("\n") ?? [];
119
+ // The last line of a text still arriving may be cut short.
120
+ const settled = complete ? next.length : next.length - 1;
121
+ let start = 0;
122
+ while (start < Math.min(old.length, settled) && old[start] === next[start]) start++;
123
+ let end = 0;
124
+ if (complete)
125
+ while (
126
+ end < Math.min(old.length, next.length) - start &&
127
+ old[old.length - 1 - end] === next[next.length - 1 - end]
128
+ )
129
+ end++;
130
+ return {
131
+ before: old.slice(0, start),
132
+ out: old.slice(start, old.length - end),
133
+ in: next.slice(start, next.length - end),
134
+ after: old.slice(old.length - end),
135
+ };
136
+ }
137
+
138
+ /** The first line pi's own renderer draws, so nothing moves when it takes over. */
139
+ function pathLine(tool: string, path: string, theme: Theme): string {
140
+ const home = homedir();
141
+ const shown = path.startsWith(home) ? `~${path.slice(home.length)}` : path;
142
+ return `${theme.fg("toolTitle", theme.bold(tool))} ${theme.fg("accent", shown)}`;
143
+ }
144
+
145
+ function above(hidden: number, gutter: number, theme: Theme): string[] {
146
+ if (hidden <= 0) return [];
147
+ const lines = `${hidden} line${hidden === 1 ? "" : "s"} above`;
148
+ return [
149
+ `${" ".repeat(Math.max(0, gutter - 1))}${theme.fg("dim", GLYPH.ellipsisUp)} ${theme.fg("muted", lines)}`,
150
+ ];
151
+ }
152
+
153
+ function withCursor(lines: readonly string[], theme: Theme): string[] {
154
+ if (lines.length === 0) return [];
155
+ return [...lines.slice(0, -1), `${lines.at(-1)}${theme.fg("accent", CURSOR)}`];
156
+ }
157
+
158
+ function replaces(target: Target): string {
159
+ if (!target.exists) return "new file";
160
+ if (target.lines === undefined) return "replaces a file";
161
+ return `replaces ${target.lines} line${target.lines === 1 ? "" : "s"}`;
162
+ }
163
+
164
+ /** Reads the file once per call, when the path is complete. */
165
+ function targetOf(path: string, context: StreamContext): Target {
166
+ const known = context.state.streamTarget;
167
+ if (known?.path === path) return known;
168
+ const full = resolve(context.cwd, path);
169
+ let target: Target = { path, exists: false };
170
+ try {
171
+ const info = statSync(full);
172
+ target = { path, exists: true };
173
+ if (info.isFile() && info.size <= COUNT_LIMIT) {
174
+ const text = readFileSync(full, "utf8");
175
+ target.lines = text === "" ? 0 : text.replace(/\n$/, "").split("\n").length;
176
+ }
177
+ } catch (error) {
178
+ // A path that is not there is a new file; one that cannot be read is still a file.
179
+ target = { path, exists: (error as NodeJS.ErrnoException).code !== "ENOENT" };
180
+ }
181
+ context.state.streamTarget = target;
182
+ return target;
183
+ }
184
+
185
+ function size(text: string): string {
186
+ const bytes = Buffer.byteLength(text);
187
+ if (bytes < 1024) return `${bytes} B`;
188
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
189
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
190
+ }
191
+
192
+ function pathOf(args: unknown): string | undefined {
193
+ const path = field(args, "path") ?? field(args, "file_path");
194
+ return path === "" ? undefined : path;
195
+ }
196
+
197
+ function field(args: unknown, key: string): string | undefined {
198
+ const value = isObject(args) ? args[key] : undefined;
199
+ return typeof value === "string" ? value : undefined;
200
+ }
201
+
202
+ export interface PartialEdit {
203
+ oldText?: string;
204
+ newText?: string;
205
+ }
206
+
207
+ /** The edits written so far. The last one may have its old text and nothing else yet. */
208
+ function editsOf(args: unknown): PartialEdit[] {
209
+ if (!isObject(args)) return [];
210
+ const { edits } = args;
211
+ const list = Array.isArray(edits) ? edits : [args];
212
+ return list.flatMap((edit) => {
213
+ const oldText = field(edit, "oldText");
214
+ const newText = field(edit, "newText");
215
+ return oldText === undefined && newText === undefined ? [] : [{ oldText, newText }];
216
+ });
217
+ }
@@ -1,8 +1,21 @@
1
1
  // The mark and the name a tool call is drawn with. The mark follows the icon set in the look's
2
2
  // settings, like every other glyph on the screen.
3
+ import { GLYPH, SPIN } from "@adeildo/pi-kit";
4
+
3
5
  import type { IconSet } from "./glyphs.ts";
4
6
 
5
- type ToolKind = "shell" | "read" | "search" | "edit" | "web" | "agent" | "mcp" | "other";
7
+ type ToolKind =
8
+ | "shell"
9
+ | "read"
10
+ | "search"
11
+ | "edit"
12
+ | "web"
13
+ | "agent"
14
+ | "ask"
15
+ | "peers"
16
+ | "memory"
17
+ | "mcp"
18
+ | "other";
6
19
 
7
20
  const ICONS: Record<IconSet, Record<ToolKind, string>> = {
8
21
  nerd: {
@@ -12,26 +25,35 @@ const ICONS: Record<IconSet, Record<ToolKind, string>> = {
12
25
  edit: "\u{f044}", // nf-fa-pencil
13
26
  web: "\u{f0ac}", // nf-fa-globe
14
27
  agent: "\u{f06a9}", // nf-md-robot
28
+ ask: "\u{f059}", // nf-fa-circle_question
29
+ peers: "\u{f0c0}", // nf-fa-users
30
+ memory: "\u{f09d1}", // nf-md-brain
15
31
  mcp: "\u{f1e6}", // nf-fa-plug
16
32
  other: "\u{f013}", // nf-fa-gear
17
33
  },
18
34
  unicode: {
19
- shell: "\u276f",
35
+ shell: GLYPH.pointer,
20
36
  read: "\u25a4",
21
- search: "\u2315",
22
- edit: "\u270e",
37
+ search: GLYPH.search,
38
+ edit: GLYPH.pencil,
23
39
  web: "\u25cd",
24
- agent: "\u25c6",
40
+ agent: GLYPH.diamond,
41
+ ask: "?",
42
+ peers: "\u2042",
43
+ memory: GLYPH.diamondEmpty,
25
44
  mcp: "\u226b",
26
- other: "\u25cb",
45
+ other: GLYPH.ring,
27
46
  },
28
47
  ascii: {
29
48
  shell: "$",
30
49
  read: "=",
31
- search: "?",
50
+ search: "/",
32
51
  edit: "+",
33
52
  web: "@",
34
53
  agent: "*",
54
+ ask: "?",
55
+ peers: "&",
56
+ memory: "#",
35
57
  mcp: ">",
36
58
  other: "-",
37
59
  },
@@ -48,17 +70,48 @@ const MARKS: Record<IconSet, Record<CallPhase, string>> = {
48
70
  failed: "\u{f00d}", // nf-fa-times
49
71
  },
50
72
  unicode: {
51
- writing: "\u270e",
73
+ writing: GLYPH.pencil,
52
74
  waiting: "\u29d6",
53
75
  running: "\u25f7",
54
- done: "\u2713",
55
- failed: "\u2717",
76
+ done: GLYPH.check,
77
+ failed: GLYPH.cross,
56
78
  },
57
79
  ascii: { writing: "~", waiting: ":", running: "...", done: "ok", failed: "!" },
58
80
  };
59
81
 
60
- export function stateMark(phase: CallPhase, set: IconSet): string {
61
- return MARKS[set][phase];
82
+ /** A mark that moves while its phase lasts: frames, and how long each stays. */
83
+ interface Motion {
84
+ frames: readonly string[];
85
+ stepMs: number;
86
+ }
87
+
88
+ /** A clock outline for every hour, from twelve round to eleven. */
89
+ const CLOCK = [0xf1456, ...Array.from({ length: 11 }, (_, hour) => 0xf144b + hour)].map((code) =>
90
+ String.fromCodePoint(code),
91
+ );
92
+
93
+ const MOTIONS: Record<IconSet, Partial<Record<CallPhase, Motion>>> = {
94
+ nerd: {
95
+ // nf-md-pencil_outline and nf-md-pencil: the pencil fills with ink and empties again.
96
+ writing: { frames: ["\u{f0cb6}", "\u{f03eb}"], stepMs: 350 },
97
+ waiting: { frames: ["\u{f251}", "\u{f252}", "\u{f253}"], stepMs: 400 }, // nf-fa-hourglass_start..end
98
+ running: { frames: CLOCK, stepMs: 110 }, // nf-md-clock_time_*_outline
99
+ },
100
+ unicode: {
101
+ writing: { frames: SPIN.pencil, stepMs: 180 },
102
+ waiting: { frames: SPIN.hourglass, stepMs: 500 },
103
+ running: { frames: SPIN.quadrants, stepMs: 150 },
104
+ },
105
+ ascii: {
106
+ running: { frames: SPIN.ascii, stepMs: 150 },
107
+ },
108
+ };
109
+
110
+ /** The mark of a phase. A phase that lasts moves, so a call that is still going looks alive. */
111
+ export function stateMark(phase: CallPhase, set: IconSet, now: number = Date.now()): string {
112
+ const motion = MOTIONS[set][phase];
113
+ if (motion === undefined) return MARKS[set][phase];
114
+ return motion.frames[Math.floor(now / motion.stepMs) % motion.frames.length] ?? MARKS[set][phase];
62
115
  }
63
116
 
64
117
  export function toolIcon(toolName: string, set: IconSet): string {
@@ -73,6 +126,7 @@ export function toolLabel(toolName: string): string {
73
126
 
74
127
  function toolKind(toolName: string): ToolKind {
75
128
  if (toolName.startsWith("mcp__")) return "mcp";
129
+ if (toolName.startsWith("memory_")) return "memory";
76
130
  switch (toolName) {
77
131
  case "bash":
78
132
  case "powershell":
@@ -94,11 +148,21 @@ function toolKind(toolName: string): ToolKind {
94
148
  case "fetch":
95
149
  case "webfetch":
96
150
  case "web_fetch":
151
+ case "fetch_content":
152
+ case "get_search_content":
153
+ case "source_check":
97
154
  return "web";
98
155
  case "task":
99
156
  case "agent":
100
157
  case "subagent":
158
+ case "bg_wait":
101
159
  return "agent";
160
+ case "ask_questions":
161
+ return "ask";
162
+ case "peers":
163
+ return "peers";
164
+ case "scratchpad":
165
+ return "memory";
102
166
  default:
103
167
  return "other";
104
168
  }
package/src/settings.ts CHANGED
@@ -1,244 +1,95 @@
1
- import {
2
- boolean,
3
- type Control,
4
- type Decoder,
5
- integer,
6
- literal,
7
- pass,
8
- problem,
9
- setting,
10
- } from "@adeildo/pi-kit";
1
+ import { boolean, literal, setting } from "@adeildo/pi-kit";
11
2
 
12
3
  import type { FrameStyle } from "./render/frame.ts";
13
4
  import type { IconMode } from "./render/glyphs.ts";
14
- import { isSegmentId, SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
5
+ import type { SegmentId } from "./render/segments.ts";
6
+ import { framePreview, iconsPreview, userFramePreview } from "./ui/previews.ts";
7
+ import type { SlotName } from "./ui/screen.ts";
15
8
 
16
- const PERMISSION_STATUS: SegmentId = "status:pi-ask-permission:mode";
9
+ // The look has opinions: where each piece of information sits, the separators, the cursor and the
10
+ // start screen are fixed. Only what depends on your terminal or your taste in lines is a setting:
11
+ // the icon set, the frame's line style, and whether your messages are framed. See DESIGN.md.
17
12
 
18
- const STATUS_LABELS: Partial<Record<SegmentId, string>> = {
19
- [PERMISSION_STATUS]: "permission mode",
20
- };
21
-
22
- export const SECTIONS = ["Editor", "Slots", "Footer", "Start screen", "Working line"];
13
+ export const SECTIONS = ["Appearance"];
23
14
 
24
- export type HeaderStyle = "card" | "compact" | "off";
25
- export type SeparatorStyle = "dot" | "bar" | "slash" | "space";
15
+ export type HeaderStyle = "card" | "compact";
26
16
  export type CursorStyle = "block" | "bar" | "underline";
27
17
 
28
- export const SEPARATORS: Record<SeparatorStyle, string> = {
29
- dot: " · ",
30
- bar: " │ ",
31
- slash: " / ",
32
- space: " ",
18
+ export const PERMISSION_MODE: SegmentId = "status:pi-ask-permission:mode";
19
+
20
+ /** The line above the editor: how long the agent has worked, and the last call that finished. */
21
+ export const strip: readonly SegmentId[] = ["elapsed"];
22
+ /** The other side of that line, or a line of its own under it when it does not fit. */
23
+ export const stripRight: readonly SegmentId[] = ["tasks", "peers"];
24
+ /** After the working spinner on the editor's top edge. */
25
+ export const topLeft: readonly SegmentId[] = ["branch", "session"];
26
+ /** Where you are. */
27
+ export const topRight: readonly SegmentId[] = ["path", "host"];
28
+ /** How what you type is handled, and who does the work. */
29
+ export const bottomLeft: readonly SegmentId[] = [PERMISSION_MODE, "model", "effort"];
30
+ /** How full the context is. */
31
+ export const bottomRight: readonly SegmentId[] = ["context"];
32
+ /** The session: cost, tokens, cache, speeds, and what other packages report. */
33
+ export const below: readonly SegmentId[] = ["cost", "tokens", "cache", "average", "statuses"];
34
+ /** The plan's quota: what each window spent, and when it resets. */
35
+ export const belowRight: readonly SegmentId[] = ["quota"];
36
+
37
+ /** Where each piece of the screen comes from. The docs fixture draws with the same map. */
38
+ export const SLOTS: Record<SlotName, readonly SegmentId[]> = {
39
+ strip,
40
+ stripRight,
41
+ topLeft,
42
+ topRight,
43
+ bottomLeft,
44
+ bottomRight,
45
+ below,
46
+ belowRight,
33
47
  };
34
48
 
35
- function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
36
- return {
37
- decode(input, path) {
38
- if (input === undefined) return pass([...fallback]);
39
- if (!Array.isArray(input))
40
- return pass([...fallback], [problem(path, "expected an array of segment ids")]);
41
- const value: SegmentId[] = [];
42
- const unknown: string[] = [];
43
- for (const entry of input) {
44
- if (typeof entry === "string" && isSegmentId(entry)) value.push(entry);
45
- else unknown.push(String(entry));
46
- }
47
- const problems =
48
- unknown.length === 0
49
- ? []
50
- : [problem(path, `ignored unknown segments: ${unknown.join(", ")}`)];
51
- return pass(value, problems);
52
- },
53
- };
54
- }
55
-
56
- // Any other package's status can have a slot of its own, so the ones already placed are offered too.
57
- function slotControl(fallback: readonly SegmentId[]): Control {
58
- const statuses = [PERMISSION_STATUS, ...fallback.filter((id) => id.startsWith("status:"))];
59
- return {
60
- type: "list",
61
- options: [
62
- ...SEGMENT_IDS.map((id) => ({ value: id, description: SEGMENTS[id].describe })),
63
- ...[...new Set(statuses)].map((id) => ({
64
- value: id,
65
- label: STATUS_LABELS[id] ?? id.slice("status:".length),
66
- description: `what ${id.slice("status:".length)} reports`,
67
- })),
68
- ],
69
- };
70
- }
71
-
72
- function slot(id: string, label: string, description: string, fallback: readonly SegmentId[]) {
73
- return setting<SegmentId[]>({
74
- id: `look.${id}`,
75
- default: [...fallback],
76
- decoder: segments(fallback),
77
- project: true,
78
- ui: { section: "Slots", label, description, control: slotControl(fallback) },
79
- });
80
- }
81
-
82
- export const strip = slot(
83
- "strip",
84
- "Answer strip",
85
- "The line above the editor: how long the agent has worked, and the last call that finished.",
86
- ["elapsed", "last"],
87
- );
88
- export const stripRight = slot(
89
- "stripRight",
90
- "Answer strip, on the right",
91
- "The other side of that line. When it does not fit beside the strip, it gets a line of its own under it.",
92
- ["tasks", "peers"],
93
- );
94
- export const topLeft = slot("frame.topLeft", "Frame, top left", "After the working spinner.", [
95
- "branch",
96
- "session",
97
- ]);
98
- export const topRight = slot("frame.topRight", "Frame, top right", "Where you are.", [
99
- "path",
100
- "host",
101
- ]);
102
- export const PERMISSION_MODE: SegmentId = PERMISSION_STATUS;
103
-
104
- export const bottomLeft = slot(
105
- "frame.bottomLeft",
106
- "Frame, bottom left",
107
- "How what you type is handled, and who does the work.",
108
- [PERMISSION_MODE, "model", "effort"],
109
- );
110
- export const bottomRight = slot("frame.bottomRight", "Frame, bottom right", "How full it is.", [
111
- "context",
112
- ]);
113
- export const below = slot(
114
- "below",
115
- "Below the editor",
116
- "The session: cost, tokens, cache, average speeds, and what other packages report, last because it changes the most.",
117
- ["cost", "tokens", "cache", "average", "statuses"],
118
- );
119
- export const belowRight = slot(
120
- "belowRight",
121
- "Below the editor, on the right",
122
- "The plan's quota: what each window spent, and when it resets.",
123
- ["quota"],
124
- );
125
-
126
- export const frame = setting<FrameStyle | "off">({
127
- id: "look.frame.style",
128
- default: "rounded",
129
- decoder: literal("rounded", "square", "heavy", "line", "off"),
130
- project: true,
131
- ui: {
132
- section: "Editor",
133
- label: "Frame",
134
- description:
135
- "The box around the editor. `line` keeps pi's two rules, `off` leaves the editor alone and moves the frame slots to the footer.",
136
- },
137
- });
138
-
139
- export const cursor = setting<CursorStyle>({
140
- id: "look.frame.cursor",
141
- default: "bar",
142
- decoder: literal("block", "bar", "underline"),
143
- ui: {
144
- section: "Editor",
145
- label: "Cursor",
146
- description: "Bar and underline use the terminal's own cursor, so it blinks the way it does.",
147
- },
148
- });
149
-
150
- export const header = setting<HeaderStyle>({
151
- id: "look.header",
152
- default: "card",
153
- decoder: literal("card", "compact", "off"),
154
- project: true,
155
- ui: {
156
- section: "Start screen",
157
- label: "Start screen",
158
- description: "The card pi opens with. Off keeps pi's own.",
159
- },
160
- });
49
+ export const SEPARATOR = " ";
50
+ export const cursor: CursorStyle = "bar";
51
+ export const header: HeaderStyle = "card";
52
+ export const pathLength = 40;
53
+ export const gaugeCells = 8;
54
+ export const labels = true;
161
55
 
162
56
  export const icons = setting<IconMode>({
163
57
  id: "look.icons",
164
58
  default: "auto",
165
59
  decoder: literal("auto", "nerd", "unicode", "ascii"),
166
60
  ui: {
167
- section: "Footer",
61
+ section: "Appearance",
168
62
  label: "Icons",
169
63
  description: "Auto uses Nerd Font glyphs locally, plain Unicode over SSH.",
64
+ preview: iconsPreview,
170
65
  },
171
66
  });
172
67
 
173
- export const separator = setting<SeparatorStyle>({
174
- id: "look.separator",
175
- default: "space",
176
- decoder: literal("dot", "bar", "slash", "space"),
177
- project: true,
178
- ui: { section: "Footer", label: "Separator", description: "Between the pieces of a slot." },
179
- });
180
-
181
- export const pathLength = setting({
182
- id: "look.pathLength",
183
- default: 40,
184
- decoder: integer(0, 500),
185
- project: true,
186
- ui: {
187
- section: "Footer",
188
- label: "Path length",
189
- description: "Longest the folder may be before it is shortened from the left. 0 keeps it all.",
190
- },
191
- });
192
-
193
- export const gaugeCells = setting({
194
- id: "look.gaugeCells",
195
- default: 8,
196
- decoder: integer(0, 40),
197
- project: true,
198
- ui: { section: "Footer", label: "Gauge", description: "Cells in the context gauge. 0 hides it." },
199
- });
200
-
201
- export const labels = setting({
202
- id: "look.labels",
203
- default: true,
204
- decoder: boolean,
68
+ export const frame = setting<FrameStyle | "off">({
69
+ id: "look.frame.style",
70
+ default: "rounded",
71
+ decoder: literal("rounded", "square", "heavy", "line", "off"),
205
72
  project: true,
206
73
  ui: {
207
- section: "Footer",
208
- label: "Labels",
74
+ section: "Appearance",
75
+ label: "Editor frame",
209
76
  description:
210
- "A short word before each number (out, in, ttft, took, ctx, cache). Off is denser.",
77
+ "The lines of the box around the editor. `line` keeps pi's two rules, `off` leaves the editor alone and moves what sits on its edges to the footer.",
78
+ preview: framePreview,
211
79
  },
212
80
  });
213
81
 
214
- export const peek = setting({
215
- id: "look.peek",
82
+ export const userFrame = setting({
83
+ id: "look.userFrame",
216
84
  default: true,
217
85
  decoder: boolean,
218
86
  ui: {
219
- section: "Working line",
220
- label: "Working line",
87
+ section: "Appearance",
88
+ label: "Your messages",
221
89
  description:
222
- "The spinner names the state: waiting, thinking, writing, drafting a tool call, running it.",
90
+ "Your messages in a frame like the tool calls', named you, with no blank row above and below the text. Off keeps pi's block.",
91
+ preview: userFramePreview,
223
92
  },
224
93
  });
225
94
 
226
- export const LOOK_SETTINGS = [
227
- strip,
228
- stripRight,
229
- topLeft,
230
- topRight,
231
- bottomLeft,
232
- bottomRight,
233
- below,
234
- belowRight,
235
- frame,
236
- cursor,
237
- header,
238
- icons,
239
- separator,
240
- pathLength,
241
- gaugeCells,
242
- labels,
243
- peek,
244
- ];
95
+ export const LOOK_SETTINGS = [icons, frame, userFrame];