pi-minimalist 0.1.2 → 0.2.2
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/README.md +26 -5
- package/index.ts +13 -35
- package/package.json +1 -1
- package/src/bridge.ts +76 -55
- package/src/components.ts +97 -32
- package/src/config-file.ts +26 -81
- package/src/config-ui.ts +42 -34
- package/src/config.ts +59 -37
- package/src/core-patch.ts +167 -178
- package/src/row.ts +44 -28
- package/src/run-grouping.ts +98 -52
- package/src/thinking.ts +5 -8
- package/src/tool-renderer.ts +21 -10
- package/src/tools.ts +57 -42
package/README.md
CHANGED
|
@@ -14,7 +14,11 @@ A quieter transcript for [Pi](https://pi.dev): turn bulky tool calls into one-li
|
|
|
14
14
|
|
|
15
15
|
<img width="699" height="50" alt="after" src="https://github.com/user-attachments/assets/c0e8bca4-afa7-42c1-9bf0-69f267d18d9f" />
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
**Click** a folded line to open it into separate rows. Click one row to see only its output. Click the `▾ Expanded · click to fold` line above an opened run to fold it back.
|
|
18
|
+
|
|
19
|
+
<img src="docs/click.png" width="600" alt="Clicking a folded line opens it into rows; clicking one row expands only that row">
|
|
20
|
+
|
|
21
|
+
Press **Ctrl+O** to expand tool output—including diffs and syntax highlighting. Expanded rows also wrap long commands instead of cutting them off. Nothing is removed from the session or hidden from the model; only the display changes.
|
|
18
22
|
|
|
19
23
|
## Install
|
|
20
24
|
|
|
@@ -33,7 +37,25 @@ Run **`/minimalist`** to change settings live. Start with compact tool rows, or
|
|
|
33
37
|
- **Compact thinking rows**, or keep thinking visible as it arrives.
|
|
34
38
|
- **Keep running tools visible** while grouping other calls, with an elapsed timer to show how long they take.
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
A fresh install starts on the **`full`** preset (see below): compact tool rows, grouped calls and compact thinking. Activity folding is opt-in. Choose `lite` if you prefer to see each call separately.
|
|
41
|
+
|
|
42
|
+
### Presets
|
|
43
|
+
|
|
44
|
+
The **Preset** row at the top of `/minimalist` sets four options at once: **Compact tool rows**, **Combine consecutive tool calls**, **Collapse earlier activity** and **Compact thinking rows**.
|
|
45
|
+
|
|
46
|
+
| Preset | What you get |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `off` | Pi's native tool cards. |
|
|
49
|
+
| `lite` | One-line tool rows, each call on its own line. |
|
|
50
|
+
| `full` | `lite` plus combined consecutive calls and compact thinking rows. |
|
|
51
|
+
| `max` | `full` plus collapsed earlier activity (`Worked for …`). |
|
|
52
|
+
| `custom` | Your own values for those four options. |
|
|
53
|
+
|
|
54
|
+
<img src="docs/presets.png" width="600" alt="Clicking a folded line opens it into rows; clicking one row expands only that row">
|
|
55
|
+
|
|
56
|
+
A preset never overwrites your settings. While `off`, `lite`, `full` or `max` is selected, only those four rows are greyed out and show the preset's values; pressing Enter on one does nothing. Every other option (Left border, Elapsed timer, Symbols and so on) stays yours and stays editable. Your own values are kept and come back when you choose `custom`. The first time you choose `custom`, it starts from `lite`.
|
|
57
|
+
|
|
58
|
+
**Upgrading:** if your settings already contain a `minimalist` block, you stay on `custom` and nothing changes. Only a user with no `minimalist` settings at all starts on `full`, which makes thinking rows compact. To pick another look, change **Preset**.
|
|
37
59
|
|
|
38
60
|
### Everyday controls
|
|
39
61
|
|
|
@@ -43,15 +65,14 @@ Compact tool rows and consecutive-call grouping are **on by default**. Activity
|
|
|
43
65
|
| `/minimalist status` | Show current settings |
|
|
44
66
|
| Ctrl+O | Expand or collapse tool output |
|
|
45
67
|
| Ctrl+T | Expand or collapse thinking |
|
|
46
|
-
| Restore defaults | Reset display settings in the editor |
|
|
47
68
|
|
|
48
69
|
Settings apply to existing conversation history and are saved globally. If a symbol looks wrong in your terminal, choose **Symbols → ASCII**. No Nerd Font required.
|
|
49
70
|
|
|
50
71
|
## Go back to Pi's usual view
|
|
51
72
|
|
|
52
|
-
Turn off **Compact tool rows** to restore native tool cards.
|
|
73
|
+
Turn off **Compact tool rows** to restore native tool cards. Or choose the `off` preset.
|
|
53
74
|
|
|
54
|
-
To disable the extension completely, run `pi config`, disable pi-minimalist, and restart Pi
|
|
75
|
+
To disable the extension completely, run `pi config`, disable pi-minimalist, and restart Pi
|
|
55
76
|
|
|
56
77
|
## Compatibility
|
|
57
78
|
|
package/index.ts
CHANGED
|
@@ -22,8 +22,8 @@ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
|
22
22
|
// keybindings and colours, as Pi's own `/settings`.
|
|
23
23
|
import { SettingsList } from "@earendil-works/pi-tui";
|
|
24
24
|
import { installBridges, sharedState } from "./src/bridge.ts";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
25
|
+
import { type BasicKey, type BasicSettings, type Config } from "./src/config.ts";
|
|
26
|
+
import { customSeed, loadSettings, saveBasicSettings } from "./src/config-file.ts";
|
|
27
27
|
import { argumentCompletions, createConfigScreen, summary } from "./src/config-ui.ts";
|
|
28
28
|
import { patchCore } from "./src/core-patch.ts";
|
|
29
29
|
import { refreshMcpTools } from "./src/tools.ts";
|
|
@@ -34,15 +34,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
34
34
|
// strand every row on stale state.
|
|
35
35
|
const { config, grouping } = sharedState(loadSettings());
|
|
36
36
|
|
|
37
|
-
// One-time migration of the preference written by the removed `/quiet`
|
|
38
|
-
// command. A session override, so the per-turn re-read below cannot revert it
|
|
39
|
-
// before the user has saved anything.
|
|
40
|
-
const migrated = migratedQuiet();
|
|
41
|
-
if (migrated !== undefined) config.setSessionOverride("groupToolRuns", migrated);
|
|
42
|
-
|
|
43
37
|
installBridges({ config, grouping });
|
|
44
|
-
//
|
|
45
|
-
// /reload only refreshes the bridge slots the wrappers read.
|
|
38
|
+
// Idempotent, so a /reload only refreshes the Bridge the wrappers read.
|
|
46
39
|
patchCore(core);
|
|
47
40
|
|
|
48
41
|
const refresh = () => {
|
|
@@ -82,7 +75,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
82
75
|
theme: getSettingsListTheme(),
|
|
83
76
|
config,
|
|
84
77
|
onChange: (key, value) => persist(ctx, config, key, value),
|
|
85
|
-
onReset: () => persistDefaults(ctx, config),
|
|
86
78
|
onClose: () => done(),
|
|
87
79
|
}),
|
|
88
80
|
);
|
|
@@ -92,37 +84,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
92
84
|
|
|
93
85
|
type NotifyContext = { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } };
|
|
94
86
|
|
|
95
|
-
/**
|
|
96
|
-
* Write only the changed key to the global agent settings, and be honest when that is impossible.
|
|
97
|
-
*
|
|
98
|
-
* The live value has already changed, so a silent failure would leave the UI and
|
|
99
|
-
* the file disagreeing. Keeping it as a session override means the per-turn
|
|
100
|
-
* re-read cannot revert what the message says was applied.
|
|
101
|
-
*/
|
|
102
|
-
function persistDefaults(ctx: NotifyContext, config: Config): void {
|
|
103
|
-
const result = saveBasicSettings(DEFAULT_BASIC);
|
|
104
|
-
for (const key of BASIC_KEYS) {
|
|
105
|
-
if (result.ok) config.clearSessionOverride(key);
|
|
106
|
-
else config.setSessionOverride(key, config.get(key));
|
|
107
|
-
}
|
|
108
|
-
if (!result.ok) {
|
|
109
|
-
ctx.ui.notify(`Defaults applied for this session only; settings.json could not be written (${result.reason}).`, "warning");
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
87
|
function persist(
|
|
114
88
|
ctx: NotifyContext,
|
|
115
89
|
config: Config,
|
|
116
90
|
key: BasicKey,
|
|
117
91
|
value: BasicSettings[BasicKey],
|
|
118
92
|
): void {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
93
|
+
// The first switch to `custom` starts from the `lite` look (see customSeed).
|
|
94
|
+
const seed = key === "preset" && value === "custom" ? customSeed() : undefined;
|
|
95
|
+
const changes: Partial<Record<BasicKey, unknown>> = { ...seed, [key]: value };
|
|
96
|
+
const result = saveBasicSettings(changes as Partial<BasicSettings>);
|
|
97
|
+
for (const [k, v] of Object.entries(changes) as [BasicKey, never][]) {
|
|
98
|
+
config.set(k, v);
|
|
99
|
+
// On success the file agrees, so it becomes the source of truth again.
|
|
100
|
+
if (result.ok) config.clearSessionOverride(k);
|
|
101
|
+
else config.setSessionOverride(k, v);
|
|
124
102
|
}
|
|
125
|
-
|
|
103
|
+
if (result.ok) return;
|
|
126
104
|
ctx.ui.notify(
|
|
127
105
|
result.reason === "comments"
|
|
128
106
|
? `Applied for this session only. settings.json has comments, which JSON.stringify would delete — set "minimalist": { "${key}": ${JSON.stringify(value)} } by hand to persist.`
|
package/package.json
CHANGED
package/src/bridge.ts
CHANGED
|
@@ -2,56 +2,72 @@
|
|
|
2
2
|
* The boundary between this extension and Pi's core components.
|
|
3
3
|
*
|
|
4
4
|
* Nothing on disk is patched. `core-patch.ts` wraps Pi's real component
|
|
5
|
-
* prototypes at load time and READS
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* prototypes at load time and READS ONE process-global object, the `Bridge`
|
|
6
|
+
* below, at CALL time. Every install replaces that object as a whole, so
|
|
7
|
+
* `/reload` swaps behavior without re-wrapping anything, and an unloaded
|
|
8
|
+
* extension (no object) degrades to Pi's native rendering.
|
|
8
9
|
*
|
|
9
|
-
* SINGLE SOURCE OF TRUTH for the
|
|
10
|
-
* that must survive `/reload`, and the tests.
|
|
10
|
+
* SINGLE SOURCE OF TRUTH for the global key names, shared by the wrappers, the
|
|
11
|
+
* state that must survive `/reload`, and the tests.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { Config } from "./config.ts";
|
|
14
15
|
import { RunGrouping } from "./run-grouping.ts";
|
|
15
|
-
import { Painter, type ThemeLike } from "./row.ts";
|
|
16
|
-
import { createToolRenderer } from "./tool-renderer.ts";
|
|
17
|
-
import { createThinkingPreview, singleHueThinkingTheme } from "./thinking.ts";
|
|
16
|
+
import { Painter, type Row, type ThemeLike } from "./row.ts";
|
|
17
|
+
import { createToolRenderer, type ToolRenderer } from "./tool-renderer.ts";
|
|
18
|
+
import { createThinkingPreview, singleHueThinkingTheme, type ThinkingPreview } from "./thinking.ts";
|
|
18
19
|
import type { Timers } from "./components.ts";
|
|
19
20
|
|
|
20
21
|
export const BRIDGE_SYMBOLS = {
|
|
21
|
-
/** `
|
|
22
|
-
|
|
23
|
-
/** `(text, theme, pad, streaming, owner, runIndex) => Component`. */
|
|
24
|
-
thinkingPreview: "pi.thinkingPreview",
|
|
25
|
-
/** `(markdownTheme, theme) => markdownTheme` for expanded thinking. */
|
|
26
|
-
thinkingMarkdownTheme: "pi.thinkingMarkdownTheme",
|
|
27
|
-
|
|
22
|
+
/** The `Bridge` object: everything the prototype wrappers call. */
|
|
23
|
+
bridge: "pi.minimalist.bridge",
|
|
28
24
|
/** Live settings, retained across a /reload. */
|
|
29
25
|
config: "pi.minimalist.config",
|
|
30
26
|
/** Run-grouping state, retained across a /reload. */
|
|
31
27
|
grouping: "pi.minimalist.grouping",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the prototype wrappers may call. Functions, not values: the wrappers read
|
|
32
|
+
* the object on every call, so a settings toggle takes effect without
|
|
33
|
+
* reinstalling anything.
|
|
34
|
+
*/
|
|
35
|
+
export type Bridge = {
|
|
36
|
+
/** `{ renderShell, handles, renderCall, renderResult }` for every tool row. */
|
|
37
|
+
toolRenderer: ToolRenderer;
|
|
38
|
+
thinkingPreview: ThinkingPreview;
|
|
39
|
+
/** Expanded thinking: the Markdown theme with every token in the thinking hue. */
|
|
40
|
+
thinkingMarkdownTheme: (base: Record<string, unknown>, theme: ThemeLike) => Record<string, unknown>;
|
|
41
|
+
/** `thinkingAsToolCall`: do collapsed/expanded thinking rows belong to us? */
|
|
42
|
+
compactThinking: () => boolean;
|
|
43
|
+
/** Keep a STREAMING thinking block expanded. */
|
|
44
|
+
keepActiveThinkingExpanded: () => boolean;
|
|
32
45
|
|
|
33
46
|
/** Chronology hooks that keep thinking and prose in transcript order. */
|
|
34
|
-
observeThinking:
|
|
35
|
-
observeProse: "
|
|
47
|
+
observeThinking: (owner: object, runIndex: number, streaming: boolean, hidden: boolean, timestamp?: number) => string;
|
|
48
|
+
observeProse: RunGrouping["observeProse"];
|
|
49
|
+
/** A visible row we do not fold (user message, excluded tool); it cuts runs. */
|
|
50
|
+
observeBarrier: (id: string) => void;
|
|
36
51
|
/** `undefined` = native prose, `null` = hidden, Row = folded summary. */
|
|
37
|
-
proseView:
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
|
|
52
|
+
proseView: (id: string, theme: ThemeLike) => Row | null | undefined;
|
|
53
|
+
/** Header of an opened run, drawn by a prose/native-thinking host. */
|
|
54
|
+
proseHeader: (id: string, theme: ThemeLike) => Row | undefined;
|
|
55
|
+
/** A click on a summary or header; true when handled. */
|
|
56
|
+
click: (id: string, onHeader: boolean) => boolean;
|
|
57
|
+
/** Omit core's separator before an activity summary. */
|
|
58
|
+
activitySummaryRow: (id: string) => boolean;
|
|
59
|
+
/** Normalize assistant host spacing. */
|
|
60
|
+
activityMessageView: (owner: object) => "normal" | "hidden" | "summary";
|
|
42
61
|
/**
|
|
43
62
|
* Spacer suppression for a fully hidden assistant message.
|
|
44
63
|
*
|
|
45
64
|
* There is deliberately NO tool-row equivalent: a compact row uses
|
|
46
65
|
* `renderShell: "self"`, and core's self-shell branch emits its separator
|
|
47
66
|
* inline and returns ZERO lines when the row renders nothing — spacer
|
|
48
|
-
* included.
|
|
49
|
-
* already handles.
|
|
67
|
+
* included.
|
|
50
68
|
*/
|
|
51
|
-
messageSpacer:
|
|
52
|
-
|
|
53
|
-
keepActiveThinkingExpanded: "pi.minimalist.keepActiveThinkingExpanded",
|
|
54
|
-
} as const;
|
|
69
|
+
messageSpacer: (owner: object) => boolean;
|
|
70
|
+
};
|
|
55
71
|
|
|
56
72
|
type Globals = Record<symbol, unknown>;
|
|
57
73
|
|
|
@@ -59,6 +75,11 @@ function slot(key: keyof typeof BRIDGE_SYMBOLS): symbol {
|
|
|
59
75
|
return Symbol.for(BRIDGE_SYMBOLS[key]);
|
|
60
76
|
}
|
|
61
77
|
|
|
78
|
+
/** The installed bridge, or `undefined` when the extension is not loaded. */
|
|
79
|
+
export function readBridge(): Bridge | undefined {
|
|
80
|
+
return (globalThis as Globals)[slot("bridge")] as Bridge | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
/**
|
|
63
84
|
* State that must survive `/reload`.
|
|
64
85
|
*
|
|
@@ -85,40 +106,40 @@ export type InstallOptions = SharedState & {
|
|
|
85
106
|
};
|
|
86
107
|
|
|
87
108
|
/**
|
|
88
|
-
* Install
|
|
109
|
+
* Install the bridge.
|
|
89
110
|
*
|
|
90
|
-
* Each `/reload` overwrites
|
|
111
|
+
* Each `/reload` overwrites the slots with fresh instances. Do NOT clear them
|
|
91
112
|
* from `session_shutdown`: reload ordering can let an old shutdown hook erase the
|
|
92
|
-
* newly installed
|
|
113
|
+
* newly installed bridge. Process exit clears globalThis naturally.
|
|
93
114
|
*/
|
|
94
115
|
export function installBridges({ config, grouping, timers }: InstallOptions): void {
|
|
95
116
|
const globals = globalThis as Globals;
|
|
117
|
+
const painter = (theme: ThemeLike) => new Painter(theme, config);
|
|
96
118
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
const bridge: Bridge = {
|
|
120
|
+
toolRenderer: createToolRenderer({ config, grouping, timers }),
|
|
121
|
+
thinkingPreview: createThinkingPreview({ config, grouping, timers }),
|
|
122
|
+
thinkingMarkdownTheme: (base, theme) => singleHueThinkingTheme(base, theme, config),
|
|
123
|
+
compactThinking: () => config.get("thinkingAsToolCall") === true,
|
|
124
|
+
keepActiveThinkingExpanded: () => config.get("keepActiveThinkingExpanded"),
|
|
103
125
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
// Chronology: core walks message content in transcript order, and run
|
|
127
|
+
// folding depends on that order.
|
|
128
|
+
observeThinking: (owner, runIndex, streaming, hidden, timestamp) => {
|
|
129
|
+
grouping.observeThinking(owner, runIndex, !streaming, !hidden, timestamp);
|
|
130
|
+
return grouping.thinkingId(owner, runIndex);
|
|
131
|
+
},
|
|
132
|
+
observeProse: (owner, contentIndex, signal) => grouping.observeProse(owner, contentIndex, signal),
|
|
133
|
+
observeBarrier: (id) => grouping.observeBarrier(id),
|
|
134
|
+
proseView: (id, theme) => grouping.proseView(id, painter(theme)),
|
|
135
|
+
proseHeader: (id, theme) => grouping.headerFor(id, painter(theme)),
|
|
136
|
+
click: (id, onHeader) => grouping.click(id, onHeader),
|
|
137
|
+
activitySummaryRow: (id) => grouping.isActivitySummary(id),
|
|
138
|
+
activityMessageView: (owner) => grouping.activityMessageView(owner),
|
|
139
|
+
messageSpacer: (owner) => grouping.showsMessageSpacer(owner),
|
|
109
140
|
};
|
|
110
|
-
globals[slot("observeProse")] = (
|
|
111
|
-
owner: object,
|
|
112
|
-
contentIndex: number,
|
|
113
|
-
signal: Parameters<RunGrouping["observeProse"]>[2],
|
|
114
|
-
) => grouping.observeProse(owner, contentIndex, signal);
|
|
115
|
-
globals[slot("proseView")] = (id: string, theme: ThemeLike) =>
|
|
116
|
-
grouping.proseView(id, new Painter(theme, config));
|
|
117
|
-
globals[slot("activitySummaryRow")] = (id: string) => grouping.isActivitySummary(id);
|
|
118
|
-
globals[slot("activityMessageView")] = (owner: object) => grouping.activityMessageView(owner);
|
|
119
|
-
globals[slot("messageSpacer")] = (owner: object) => grouping.showsMessageSpacer(owner);
|
|
120
141
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
globals[slot("
|
|
142
|
+
globals[slot("config")] = config;
|
|
143
|
+
globals[slot("grouping")] = grouping;
|
|
144
|
+
globals[slot("bridge")] = bridge;
|
|
124
145
|
}
|
package/src/components.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* production components without loading an extension runtime.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
10
|
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
11
11
|
import { gutterWidth, type Row } from "./row.ts";
|
|
12
12
|
|
|
@@ -40,17 +40,57 @@ export const realTimers: Timers = {
|
|
|
40
40
|
*/
|
|
41
41
|
export type ResolveRow = (width: number) => Row | null;
|
|
42
42
|
|
|
43
|
+
/** Columns the details must keep on each line before wrapping is worth it. */
|
|
44
|
+
const MIN_WRAP_COLUMNS = 10;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* An expanded row's details wrapped under themselves (a hanging indent below
|
|
48
|
+
* `glyph label`), or undefined when the terminal is too narrow to wrap
|
|
49
|
+
* usefully: the caller then truncates as usual. A body that fits wraps to a
|
|
50
|
+
* single line.
|
|
51
|
+
*/
|
|
52
|
+
function wrapped({ head, body }: { head: string; body: string }, room: number): string[] | undefined {
|
|
53
|
+
const indent = visibleWidth(head) + 1;
|
|
54
|
+
if (room - indent < MIN_WRAP_COLUMNS) return undefined;
|
|
55
|
+
const pad = " ".repeat(indent);
|
|
56
|
+
return wrapTextWithAnsi(body, room - indent).map((line, i) => (i === 0 ? `${head} ` : pad) + line);
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
/**
|
|
44
|
-
*
|
|
60
|
+
* Paint a resolved row at `width`; `null` renders ZERO lines.
|
|
61
|
+
*
|
|
62
|
+
* TUI pads each line to terminal width, so no manual trailing padding is
|
|
63
|
+
* needed. truncateToWidth understands ANSI codes and wide Unicode glyphs, so
|
|
64
|
+
* colored text truncates at VISIBLE columns. The gutter is a fixed-width
|
|
65
|
+
* prefix, so the content gets the remaining columns. Width comes from the
|
|
66
|
+
* row's OWN gutter text, so a disabled gutter reclaims those columns.
|
|
67
|
+
*/
|
|
68
|
+
function renderRow(row: Row | null, width: number): string[] {
|
|
69
|
+
if (row === null) return [];
|
|
70
|
+
const room = Math.max(1, width - gutterWidth(row.gutter));
|
|
71
|
+
const paint = (line: string) => row.gutter + line;
|
|
72
|
+
const lines = row.wrap ? wrapped(row.wrap, room) : undefined;
|
|
73
|
+
if (lines) return lines.map(paint);
|
|
74
|
+
return [paint(truncateToWidth(row.text, room, "…"))];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One physical terminal line (several for an expanded row) with width-aware
|
|
79
|
+
* truncation and full-width color.
|
|
45
80
|
*
|
|
46
81
|
* Pi's Text component wraps long strings. That is correct for prose but made a
|
|
47
|
-
* long path spill onto a second line in a
|
|
82
|
+
* long path spill onto a second line in a renderer meant for one line. TUI
|
|
48
83
|
* only supplies the real terminal width during render(), so truncating with a
|
|
49
84
|
* fixed character count in the caller cannot solve this reliably.
|
|
85
|
+
*
|
|
86
|
+
* The one exception is an EXPANDED row (`row.wrap`): there the user asked to see
|
|
87
|
+
* everything, so a long command wraps under its own details instead.
|
|
50
88
|
*/
|
|
51
89
|
export class CompactLine implements Component {
|
|
52
90
|
private resolve: ResolveRow = () => null;
|
|
53
|
-
private click?: () => boolean;
|
|
91
|
+
private click?: (onHeader: boolean) => boolean;
|
|
92
|
+
/** Did the last render draw a header line at y=0? */
|
|
93
|
+
private headed = false;
|
|
54
94
|
private ticker: unknown;
|
|
55
95
|
private timers: Timers;
|
|
56
96
|
|
|
@@ -60,9 +100,10 @@ export class CompactLine implements Component {
|
|
|
60
100
|
|
|
61
101
|
/**
|
|
62
102
|
* Install the row resolver. Called on every updateDisplay with fresh state.
|
|
63
|
-
* `click` returning true stops Pi's own left-click (expand) handler
|
|
103
|
+
* `click` returning true stops Pi's own left-click (expand) handler; its
|
|
104
|
+
* argument says whether the click landed on the row's header line.
|
|
64
105
|
*/
|
|
65
|
-
setRow(resolve: ResolveRow, click?: () => boolean): void {
|
|
106
|
+
setRow(resolve: ResolveRow, click?: (onHeader: boolean) => boolean): void {
|
|
66
107
|
this.resolve = resolve;
|
|
67
108
|
this.click = click;
|
|
68
109
|
}
|
|
@@ -71,7 +112,8 @@ export class CompactLine implements Component {
|
|
|
71
112
|
// the region's own expand/collapse toggle.
|
|
72
113
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
73
114
|
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
74
|
-
|
|
115
|
+
// `y` is local to THIS component (Container subtracts earlier siblings).
|
|
116
|
+
return this.click?.(this.headed && event.y === 0) ? { handled: true } : undefined;
|
|
75
117
|
}
|
|
76
118
|
|
|
77
119
|
/**
|
|
@@ -91,22 +133,10 @@ export class CompactLine implements Component {
|
|
|
91
133
|
}
|
|
92
134
|
}
|
|
93
135
|
|
|
94
|
-
/** Test/inspection helper: is a repaint interval currently registered? */
|
|
95
|
-
isTicking(): boolean {
|
|
96
|
-
return this.ticker !== undefined;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
136
|
render(width: number): string[] {
|
|
100
137
|
const row = this.resolve(width);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// TUI pads each line to terminal width, so no manual trailing padding is
|
|
104
|
-
// needed. truncateToWidth understands ANSI codes and wide Unicode glyphs,
|
|
105
|
-
// so colored text truncates at VISIBLE columns. The gutter is a fixed-width
|
|
106
|
-
// prefix, so the content gets the remaining columns. Width comes from the
|
|
107
|
-
// row's OWN gutter text, so a disabled gutter reclaims those columns.
|
|
108
|
-
const line = truncateToWidth(row.text, Math.max(1, width - gutterWidth(row.gutter)), "…");
|
|
109
|
-
return [row.gutter + (row.highlight ? row.highlight(line) : line)];
|
|
138
|
+
this.headed = row?.header !== undefined;
|
|
139
|
+
return [...(row?.header ? [...renderRow(row.header, width), ""] : []), ...renderRow(row, width)];
|
|
110
140
|
}
|
|
111
141
|
|
|
112
142
|
// Component contract allows cached components to be invalidated. This class
|
|
@@ -115,28 +145,55 @@ export class CompactLine implements Component {
|
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* renderResult slot contract requires.
|
|
148
|
+
* Assistant prose or native thinking that a fold may take over: the resolver
|
|
149
|
+
* returns a Row to draw it instead, `null` to hide it (zero lines), or
|
|
150
|
+
* `undefined` to leave `inner` untouched (with an opened run's header line
|
|
151
|
+
* above it, when `header` supplies one). The header is followed by one blank line. Clicks on the summary or header go to
|
|
152
|
+
* `click`.
|
|
124
153
|
*/
|
|
125
154
|
export class FoldableProse implements Component {
|
|
126
155
|
private inner: Component;
|
|
127
156
|
private resolve: (width: number) => Row | null | undefined;
|
|
157
|
+
private header: () => Row | undefined;
|
|
158
|
+
private click: (onHeader: boolean) => boolean;
|
|
159
|
+
/** What the last render drew: a folded summary, or a header line above native output. */
|
|
160
|
+
private summary = false;
|
|
161
|
+
private headed = false;
|
|
128
162
|
|
|
129
|
-
|
|
163
|
+
/**
|
|
164
|
+
* `header` is the opened-run header to draw above the native output; `click`
|
|
165
|
+
* has the same contract as `CompactLine.setRow`'s: it gets whether the click
|
|
166
|
+
* landed on that header line.
|
|
167
|
+
*/
|
|
168
|
+
constructor(
|
|
169
|
+
inner: Component,
|
|
170
|
+
resolve: (width: number) => Row | null | undefined,
|
|
171
|
+
header: () => Row | undefined,
|
|
172
|
+
click: (onHeader: boolean) => boolean,
|
|
173
|
+
) {
|
|
130
174
|
this.inner = inner;
|
|
131
175
|
this.resolve = resolve;
|
|
176
|
+
this.header = header;
|
|
177
|
+
this.click = click;
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
render(width: number): string[] {
|
|
135
181
|
const row = this.resolve(width);
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
return
|
|
182
|
+
this.summary = row !== undefined && row !== null;
|
|
183
|
+
const header = row === undefined ? this.header() : undefined;
|
|
184
|
+
this.headed = header !== undefined;
|
|
185
|
+
if (row !== undefined) return renderRow(row, width);
|
|
186
|
+
return header ? [...renderRow(header, width), "", ...this.inner.render(width)] : this.inner.render(width);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Only the summary or the header line is clickable; the native output below
|
|
190
|
+
// keeps whatever click behavior its own host (e.g. a thinking MouseRegion) has.
|
|
191
|
+
// `y` is local to THIS component, like CompactLine's.
|
|
192
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
193
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
194
|
+
const onHeader = this.headed && event.y === 0;
|
|
195
|
+
if (!this.summary && !onHeader) return undefined;
|
|
196
|
+
return this.click(onHeader) ? { handled: true } : undefined;
|
|
140
197
|
}
|
|
141
198
|
|
|
142
199
|
invalidate(): void {
|
|
@@ -144,6 +201,14 @@ export class FoldableProse implements Component {
|
|
|
144
201
|
}
|
|
145
202
|
}
|
|
146
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Collapsed results must render ZERO lines, not one blank line.
|
|
206
|
+
*
|
|
207
|
+
* Pi's Text component returns [""] for empty strings (one blank row), which
|
|
208
|
+
* doubled the height of every collapsed tool call. This is the only safe way to
|
|
209
|
+
* say "no content at all" while still returning a Component, as the
|
|
210
|
+
* renderResult slot contract requires.
|
|
211
|
+
*/
|
|
147
212
|
export class EmptyComponent implements Component {
|
|
148
213
|
render(): string[] {
|
|
149
214
|
return [];
|