@gavin-hu/my-pi-agent 0.16.0 → 0.17.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.
Files changed (135) hide show
  1. package/README.md +14 -8
  2. package/README.zh-CN.md +14 -9
  3. package/extensions/bytefly/README.md +174 -101
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/panel/commands.ts +46 -25
  17. package/extensions/bytefly/panel/format.ts +49 -14
  18. package/extensions/bytefly/panel/join.ts +35 -9
  19. package/extensions/bytefly/panel/settings.ts +10 -27
  20. package/extensions/bytefly/panel/skills.ts +51 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  22. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  23. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  24. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  25. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  26. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  27. package/extensions/bytefly/panel/tui.ts +223 -331
  28. package/extensions/bytefly/panel/types.ts +26 -8
  29. package/extensions/bytefly/picker/discovery.ts +32 -27
  30. package/extensions/bytefly/picker/resources.ts +16 -8
  31. package/extensions/bytefly/picker/runtime.ts +62 -34
  32. package/extensions/bytefly/picker/toggle.ts +16 -10
  33. package/extensions/bytefly/picker/types.ts +31 -13
  34. package/extensions/bytefly/update/format.ts +66 -8
  35. package/extensions/bytefly/update/patch.ts +66 -0
  36. package/extensions/bytefly/update/register.ts +4 -11
  37. package/extensions/bytefly/update/release.ts +65 -0
  38. package/extensions/bytefly/update/state.ts +14 -0
  39. package/extensions/bytefly/update/suppress.ts +47 -29
  40. package/extensions/bytefly/update/types.ts +15 -5
  41. package/extensions/check/tui.ts +2 -0
  42. package/extensions/context/README.md +21 -11
  43. package/extensions/context/format.ts +10 -8
  44. package/extensions/context/report.ts +21 -8
  45. package/extensions/context/tui.ts +26 -10
  46. package/extensions/context/types.ts +5 -3
  47. package/extensions/file-browse/README.md +29 -1
  48. package/extensions/file-browse/client.ts +16 -463
  49. package/extensions/file-browse/html.ts +7 -5
  50. package/extensions/file-browse/palette.ts +224 -0
  51. package/extensions/file-browse/router.ts +2 -2
  52. package/extensions/file-browse/styles.ts +8 -3
  53. package/extensions/file-browse/tree.ts +381 -0
  54. package/extensions/file-read/README.md +4 -1
  55. package/extensions/file-read/extract/odt.ts +7 -5
  56. package/extensions/goal/tools.ts +6 -11
  57. package/extensions/job/index.ts +20 -45
  58. package/extensions/job/tools.ts +51 -77
  59. package/extensions/memory/tools.ts +4 -6
  60. package/extensions/memory/tui.ts +5 -1
  61. package/extensions/plan/README.md +88 -33
  62. package/extensions/plan/commands.ts +19 -4
  63. package/extensions/plan/config.ts +27 -0
  64. package/extensions/plan/plan-index.ts +3 -1
  65. package/extensions/plan/plans.ts +31 -13
  66. package/extensions/plan/review.ts +34 -14
  67. package/extensions/plan/runtime.ts +3 -1
  68. package/extensions/plan/steps.ts +43 -14
  69. package/extensions/plan/tools/cancel.ts +79 -0
  70. package/extensions/plan/tools/enter.ts +10 -10
  71. package/extensions/plan/tools/exit.ts +54 -43
  72. package/extensions/plan/tools/steps.ts +7 -19
  73. package/extensions/plan/tools/write.ts +22 -29
  74. package/extensions/plan/tools.ts +5 -2
  75. package/extensions/plan/tui.ts +9 -7
  76. package/extensions/plan/types.ts +8 -0
  77. package/extensions/plan/web-chrome.ts +101 -0
  78. package/extensions/plan/web-client.ts +98 -311
  79. package/extensions/plan/web-markdown.ts +222 -0
  80. package/extensions/plan/web-page.ts +28 -2
  81. package/extensions/plan/web-router.ts +2 -2
  82. package/extensions/rewind/checklist.ts +4 -0
  83. package/extensions/rewind/review-tui.ts +3 -1
  84. package/extensions/rewind/rewind-tui.ts +4 -0
  85. package/extensions/subagent/README.md +25 -12
  86. package/extensions/subagent/format.ts +72 -25
  87. package/extensions/subagent/index.ts +21 -47
  88. package/extensions/subagent/orchestrate.ts +18 -41
  89. package/extensions/subagent/render.ts +29 -42
  90. package/extensions/subagent/stream.ts +24 -18
  91. package/extensions/subagent/types.ts +7 -0
  92. package/extensions/todo/tools.ts +5 -6
  93. package/extensions/todo/tui.ts +5 -1
  94. package/extensions/watch/index.ts +10 -35
  95. package/extensions/watch/tools.ts +31 -59
  96. package/extensions/wechat/README.md +40 -18
  97. package/extensions/wechat/bridge.ts +38 -40
  98. package/extensions/wechat/client.ts +1 -4
  99. package/extensions/wechat/commands.ts +1 -1
  100. package/extensions/wechat/interaction.ts +25 -4
  101. package/extensions/wechat/login-screen.ts +2 -3
  102. package/extensions/wechat/notices.ts +7 -5
  103. package/extensions/wechat/prompt.ts +19 -4
  104. package/extensions/wechat/qrcode.ts +9 -9
  105. package/extensions/wechat/slash.ts +14 -0
  106. package/extensions/wechat/status-screen.ts +2 -2
  107. package/extensions/wechat/tools.ts +3 -11
  108. package/extensions/worktree/README.md +48 -12
  109. package/extensions/worktree/commands.ts +109 -65
  110. package/extensions/worktree/enter.ts +1 -1
  111. package/extensions/worktree/exit.ts +11 -1
  112. package/extensions/worktree/lifecycle.ts +9 -1
  113. package/extensions/worktree/screen.ts +263 -0
  114. package/extensions/worktree/status.ts +84 -38
  115. package/lib/README.md +7 -6
  116. package/lib/config.ts +12 -0
  117. package/lib/format.ts +6 -2
  118. package/lib/interaction.ts +18 -1
  119. package/lib/report.ts +75 -2
  120. package/lib/tool-result.ts +54 -4
  121. package/lib/tui/README.md +16 -8
  122. package/lib/tui/chrome.ts +27 -6
  123. package/lib/tui/dock-screen.ts +6 -2
  124. package/lib/tui/gauge.ts +9 -5
  125. package/lib/usage.ts +26 -0
  126. package/lib/web/README.md +131 -0
  127. package/lib/web/base.ts +19 -2
  128. package/lib/web/components.ts +10 -6
  129. package/lib/web/layout.ts +2 -0
  130. package/lib/web/markup.ts +2 -1
  131. package/lib/web/runtime.ts +103 -25
  132. package/lib/web/tokens.ts +23 -7
  133. package/package.json +3 -1
  134. package/skills/changelog/SKILL.md +57 -45
  135. package/skills/next/SKILL.md +184 -0
@@ -19,6 +19,7 @@ import {
19
19
  import { clip, fitCells, splitRow } from "../../../../lib/tui/columns.ts";
20
20
  import { fitRows, formatRange, type ListCursor } from "../../../../lib/tui/cursor.ts";
21
21
  import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
22
+ import { emptyState } from "../../../../lib/tui/rows.ts";
22
23
  import { paint } from "../../../../lib/tui/tokens.ts";
23
24
  import { sectionSegments, type SummarySegment } from "../format.ts";
24
25
  import type { PanelSection } from "../types.ts";
@@ -26,7 +27,7 @@ import { selectableRange, type PanelEntry } from "./entries.ts";
26
27
 
27
28
  /** Body rows when the terminal height is unknown. */
28
29
  export const SCREEN_DEFAULT_ROWS = 16;
29
- /** Fixed rows around the body: header, summary, blank, blank, hint, blank. */
30
+ /** Fixed rows around the body: header, summary, blank, blank, hint, closing rule. */
30
31
  export const FRAME_ROWS = 6;
31
32
  /** Detail rows reserved for the focused row: its head, one description line, and its origin. */
32
33
  export const DETAIL_ROWS = 3;
@@ -142,6 +143,8 @@ export interface PanelFrameInput {
142
143
  note?: string;
143
144
  detail: string[];
144
145
  hint: string;
146
+ /** The line a section shows in place of its body when it has no entries. */
147
+ emptyLabel?: string;
145
148
  draw: (entry: PanelEntry, index: number) => string | undefined;
146
149
  }
147
150
 
@@ -164,6 +167,9 @@ export function panelFrame(input: PanelFrameInput): { lines: string[]; noteShown
164
167
 
165
168
  const body: string[] = [];
166
169
  if (note) body.push(note);
170
+ if (entries.length === 0 && input.emptyLabel !== undefined) {
171
+ body.push(...emptyState(theme, width, { label: input.emptyLabel }));
172
+ }
167
173
  for (let index = start; index < end; index += 1) {
168
174
  const line = draw(entries[index], index);
169
175
  if (line !== undefined) body.push(line);
@@ -175,6 +181,8 @@ export function panelFrame(input: PanelFrameInput): { lines: string[]; noteShown
175
181
  const fitted = fitOptionalLines({ viewportRows, body, note, range, detail });
176
182
  return {
177
183
  lines: dockListFrame({
184
+ theme,
185
+ width,
178
186
  header: input.header,
179
187
  summary: input.summary,
180
188
  body: fitted.body,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The grouped list sections' shared pieces: the headline counts, one row, and
3
+ * the key hints.
4
+ *
5
+ * Extensions and Skills draw the same row grammar — a checkbox, a verdict, and
6
+ * the resource's name — and offer the same keys, so the row, the summary, and
7
+ * the hint row live here and each section supplies only what it can say about
8
+ * the focused row and whether it has anything to refresh.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { sanitizeLine } from "../../../../lib/format.ts";
13
+ import type { ScreenHint } from "../../../../lib/tui/chrome.ts";
14
+ import { checkbox, listRow } from "../../../../lib/tui/rows.ts";
15
+ import { rowBadge, type PanelScope, summarySegments, type SummaryOptions } from "../format.ts";
16
+ import type { PanelModel, PanelRow } from "../types.ts";
17
+ import { countsLine } from "./frame.ts";
18
+
19
+ /** The section's headline: the scope, the row count, and what is off. */
20
+ export function resourceSummary(
21
+ theme: Theme,
22
+ width: number,
23
+ model: PanelModel,
24
+ scope: PanelScope,
25
+ options: SummaryOptions = {},
26
+ ): string {
27
+ return countsLine(theme, width, summarySegments(model, scope, { ...options, compact: width < 72 }));
28
+ }
29
+
30
+ /** One resource row: its checkbox, verdict, and name. */
31
+ export function resourceRow(theme: Theme, width: number, row: PanelRow, selected: boolean): string {
32
+ const badge = rowBadge(row);
33
+ return listRow(theme, width, {
34
+ selected,
35
+ // A reported-only row has no enable pattern, but still reserves the box
36
+ // column so the list stays aligned.
37
+ leading: row.item ? checkbox(theme, row.item.enabled) : " ",
38
+ glyph: badge.glyph ?? " ",
39
+ glyphRole: badge.role,
40
+ label: sanitizeLine(row.label),
41
+ labelRole: selected ? "accent" : row.item && !row.item.enabled ? "faint" : "label",
42
+ trailing: badge.text,
43
+ // The glyph carries the status colour; the word stays quiet unless it is a
44
+ // problem, so a clean row does not shout.
45
+ trailingRole: badge.role === "bad" || badge.role === "warn" ? badge.role : "faint",
46
+ // The verdict sits beside the name instead of at the far edge of a wide
47
+ // terminal, where a two-word verdict would be unreadable.
48
+ align: "inline",
49
+ });
50
+ }
51
+
52
+ export interface SectionHintOptions {
53
+ expanded: boolean;
54
+ projectScope: boolean;
55
+ /** The refresh key's state; omitted by a section with nothing to refresh. */
56
+ refreshing?: boolean;
57
+ }
58
+
59
+ /** The section's key hints, in the order they drop as the width runs out. */
60
+ export function sectionHints({ expanded, projectScope, refreshing }: SectionHintOptions): ScreenHint[] {
61
+ return [
62
+ "Esc close",
63
+ "←/→",
64
+ { text: "Space toggle", color: "accent" },
65
+ { text: expanded ? "Enter collapse" : "Enter detail", color: "accent" },
66
+ "Tab scope",
67
+ // Only Project scope can hold an override, so the key is not offered elsewhere.
68
+ ...(projectScope ? ["x inherit"] : []),
69
+ ...(refreshing === undefined ? [] : [refreshing ? "refreshing…" : "r refresh"]),
70
+ "↑/↓ select",
71
+ ];
72
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Cursor arithmetic for the panel's two lists: keep the focus on a row across a
3
- * rebuild, land on the nearest row when the shape changes, and move by rows
4
- * while skipping group headers.
2
+ * Cursor arithmetic for the panel's lists: keep the focus on a row across a
3
+ * rebuild, land on the nearest row when the shape changes, move by rows while
4
+ * skipping group headers, and read one navigation key.
5
5
  *
6
6
  * Pure: each function takes the entries and the cursor and mutates only the
7
7
  * cursor it was handed, so the component keeps one cursor per section and no
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ListCursor } from "../../../../lib/tui/cursor.ts";
12
+ import { navIntent } from "../../../../lib/tui/cursor.ts";
12
13
  import { entryKey, isSelectable, nextRow, type PanelEntry } from "./entries.ts";
13
14
 
14
15
  /** The first selectable entry, or `undefined` when the list has none. */
@@ -73,3 +74,70 @@ export function moveRows(entries: PanelEntry[], cursor: ListCursor, delta: numbe
73
74
  }
74
75
  cursor.set(index, entries.length);
75
76
  }
77
+
78
+ /** Move to the first row of the next (or previous) group. */
79
+ export function jumpGroup(entries: PanelEntry[], cursor: ListCursor, direction: -1 | 1): void {
80
+ const headers = entries.flatMap((entry, index) => (entry.kind === "group" ? [index] : []));
81
+ const selected = cursor.selected;
82
+ // Step past the focused row's own header: landing on it would not move.
83
+ const own = headers.filter((index) => index < selected).at(-1);
84
+ const target =
85
+ direction === 1 ? headers.find((index) => index > selected) : headers.filter((index) => index < (own ?? 0)).at(-1);
86
+ if (target === undefined) return;
87
+ for (let index = target + 1; index < entries.length; index += 1) {
88
+ if (isSelectable(entries[index])) {
89
+ cursor.set(index, entries.length);
90
+ return;
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Move the cursor to `index`, clamped to the list. */
96
+ export function selectRow(entries: PanelEntry[], cursor: ListCursor, index: number | undefined): void {
97
+ if (index === undefined) return;
98
+ cursor.set(index, entries.length);
99
+ }
100
+
101
+ /**
102
+ * Read one navigation key against a list. `grouped` is true for the resource
103
+ * lists, whose headings the bracket keys jump between; the Settings list is flat.
104
+ */
105
+ export function navigateEntries(
106
+ entries: PanelEntry[],
107
+ cursor: ListCursor,
108
+ data: string,
109
+ options: { grouped: boolean },
110
+ ): void {
111
+ if (data === "g") {
112
+ selectRow(entries, cursor, firstRow(entries));
113
+ return;
114
+ }
115
+ if (data === "G") {
116
+ selectRow(entries, cursor, lastRow(entries));
117
+ return;
118
+ }
119
+ if (options.grouped && (data === "[" || data === "]")) {
120
+ jumpGroup(entries, cursor, data === "[" ? -1 : 1);
121
+ return;
122
+ }
123
+ switch (navIntent(data)) {
124
+ case "up":
125
+ moveRows(entries, cursor, -1);
126
+ return;
127
+ case "down":
128
+ moveRows(entries, cursor, 1);
129
+ return;
130
+ case "pageUp":
131
+ moveRows(entries, cursor, -Math.max(1, cursor.visible));
132
+ return;
133
+ case "pageDown":
134
+ moveRows(entries, cursor, Math.max(1, cursor.visible));
135
+ return;
136
+ case "home":
137
+ selectRow(entries, cursor, firstRow(entries));
138
+ return;
139
+ case "end":
140
+ selectRow(entries, cursor, lastRow(entries));
141
+ return;
142
+ }
143
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The Settings section: its own state (the editor and the JSON pane), its key
3
+ * handling, and the screen it draws.
4
+ *
5
+ * The component owns the section switch, the write scope, and the cursor. This
6
+ * class owns what only Settings has, so `tui.ts` stays the switch and the shared
7
+ * list keys, and the section reads and writes through the same surface.
8
+ */
9
+
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
12
+ import type { ViewportRowsSource } from "../../../../lib/tui/chrome.ts";
13
+ import type { ListCursor } from "../../../../lib/tui/cursor.ts";
14
+ import type { WriteScope } from "../../picker/types.ts";
15
+ import type { SettingsSurface } from "../settings-runtime.ts";
16
+ import { SETTING_ROWS } from "../settings.ts";
17
+ import { applyEditKey, type EditingState } from "./editing.ts";
18
+ import { settingEntries, type PanelEntry } from "./entries.ts";
19
+ import { detailRows, groupLine, panelFrame, sectionBar } from "./frame.ts";
20
+ import { clampSelection } from "./nav.ts";
21
+ import { projectNote, sectionJsonPane, settingDetail, settingRow, settingsSummary } from "./settings-view.ts";
22
+
23
+ /** What the Settings section needs from the component that hosts it. */
24
+ export interface SettingsSectionDeps {
25
+ /** The rows, values, and writes the section reads and calls. */
26
+ surface: SettingsSurface;
27
+ theme: Theme;
28
+ scope(): WriteScope;
29
+ cursor: ListCursor;
30
+ viewportRows: ViewportRowsSource | undefined;
31
+ cwd: string;
32
+ agentDir: string;
33
+ /** Keep the focused row across a rebuild and return it. */
34
+ retain(entries: PanelEntry[]): PanelEntry | undefined;
35
+ /** Panel navigation, for the keys this section does not own. */
36
+ navigate(data: string): void;
37
+ /** Flip the Global/Project scope. */
38
+ switchScope(): void;
39
+ notify(message: string): void;
40
+ requestRender(): void;
41
+ /** Keep the frame's project-note marker in step for the mouse mapping. */
42
+ setNoteShown(shown: boolean): void;
43
+ /** The hint row, already assembled (or the notice that replaces it). */
44
+ hint(width: number): string;
45
+ }
46
+
47
+ /** The Settings section's state and screen. */
48
+ export class SettingsSection {
49
+ private chain: Promise<void> = Promise.resolve();
50
+ private editing: EditingState | undefined;
51
+ private json = false;
52
+
53
+ constructor(private readonly deps: SettingsSectionDeps) {}
54
+
55
+ /** Whether a text row is open in the editor, so the hint row and mouse defer. */
56
+ get isEditing(): boolean {
57
+ return this.editing !== undefined;
58
+ }
59
+
60
+ /** Whether the detail pane shows the focused row's section as JSON. */
61
+ get jsonOpen(): boolean {
62
+ return this.json;
63
+ }
64
+
65
+ /** Run `task` after the previous write, then repaint once. */
66
+ private write(task: () => Promise<unknown>, onSettled?: (result: unknown) => void): void {
67
+ this.chain = this.chain
68
+ .then(task)
69
+ .then(onSettled)
70
+ .catch((error: unknown) => this.deps.notify(error instanceof Error ? error.message : String(error)))
71
+ .then(() => this.deps.requestRender());
72
+ }
73
+
74
+ /** The rows for the active scope, as display entries. */
75
+ private entries(): PanelEntry[] {
76
+ return settingEntries(this.deps.surface.views(this.deps.scope()));
77
+ }
78
+
79
+ /** The focused entry, after clamping the cursor onto a row. */
80
+ private focused(): PanelEntry | undefined {
81
+ const entries = this.entries();
82
+ clampSelection(entries, this.deps.cursor);
83
+ return entries[this.deps.cursor.selected];
84
+ }
85
+
86
+ /** Drop the editor and the JSON pane when another section takes over. */
87
+ reset(): void {
88
+ this.editing = undefined;
89
+ this.json = false;
90
+ }
91
+
92
+ /** A click leaves the editor the way `Esc` does: nothing is written. */
93
+ cancelEdit(): void {
94
+ this.reset();
95
+ this.deps.requestRender();
96
+ }
97
+
98
+ /** Change the focused setting: cycle it, or open the editor for a text row. */
99
+ private changeSetting(): void {
100
+ const entry = this.focused();
101
+ if (entry?.kind !== "setting") return;
102
+ const view = entry.setting;
103
+ if (view.kind === "text") {
104
+ this.editing = { id: view.id, buffer: view.value, caret: view.value.length };
105
+ this.deps.requestRender();
106
+ return;
107
+ }
108
+ const scope = this.deps.scope();
109
+ this.write(() => this.deps.surface.change(scope, view.id));
110
+ }
111
+
112
+ /** Delete the focused setting's leaf, so its default applies again. */
113
+ private resetSetting(): void {
114
+ const entry = this.focused();
115
+ if (entry?.kind !== "setting") return;
116
+ const scope = this.deps.scope();
117
+ this.write(() => this.deps.surface.reset(scope, entry.setting.id));
118
+ }
119
+
120
+ /** Toggle the detail pane between the row and its whole section as stored. */
121
+ private toggleJson(): void {
122
+ const entry = this.focused();
123
+ if (entry?.kind !== "setting") return;
124
+ if (!entry.setting.config) {
125
+ // There is no section to show, so leave the mode off rather than keeping a
126
+ // hidden toggle that would reopen the pane on another row.
127
+ this.json = false;
128
+ this.deps.notify(`${entry.setting.label} is a Pi setting; bytefly.json has no section for it.`);
129
+ return;
130
+ }
131
+ this.json = !this.json;
132
+ this.deps.requestRender();
133
+ }
134
+
135
+ /** Store the editor's buffer; a refusal keeps it open with the reason shown. */
136
+ private commitEdit(): void {
137
+ const editing = this.editing;
138
+ if (!editing) return;
139
+ const scope = this.deps.scope();
140
+ this.write(
141
+ () => this.deps.surface.setValue(scope, editing.id, editing.buffer),
142
+ (saved) => {
143
+ if (saved === true) this.editing = undefined;
144
+ },
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Editor keys. `Esc`/`Ctrl+C` cancel the edit rather than closing the screen,
150
+ * and everything else the section binds is inert until the editor closes.
151
+ */
152
+ private handleEditingInput(data: string): void {
153
+ const editing = this.editing;
154
+ if (!editing) return;
155
+ const max = SETTING_ROWS.find((row) => row.id === editing.id)?.text?.maxLength;
156
+ const action = applyEditKey(data, editing, max);
157
+ if (action.kind === "commit") {
158
+ void this.commitEdit();
159
+ return;
160
+ }
161
+ if (action.kind === "cancel") {
162
+ this.editing = undefined;
163
+ this.deps.requestRender();
164
+ return;
165
+ }
166
+ if (!action.changed) return;
167
+ this.editing = action.state;
168
+ this.deps.requestRender();
169
+ }
170
+
171
+ /** One key: editor keys first, then change, scope, reset, JSON, navigation. */
172
+ handleInput(data: string): void {
173
+ if (this.editing) {
174
+ this.handleEditingInput(data);
175
+ return;
176
+ }
177
+ this.focused();
178
+ if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
179
+ this.changeSetting();
180
+ return;
181
+ }
182
+ if (matchesKey(data, Key.tab)) {
183
+ this.deps.switchScope();
184
+ return;
185
+ }
186
+ if (data === "u") {
187
+ this.resetSetting();
188
+ return;
189
+ }
190
+ if (data === "J") {
191
+ this.toggleJson();
192
+ return;
193
+ }
194
+ this.deps.navigate(data);
195
+ }
196
+
197
+ /** The Settings body: scope counts, the project note, rows, and detail. */
198
+ render(width: number, header: string): string[] {
199
+ const { theme, surface, viewportRows, cwd, agentDir } = this.deps;
200
+ const scope = this.deps.scope();
201
+ const views = surface.views(scope);
202
+ const entries = settingEntries(views);
203
+ const focusedEntry = this.deps.retain(entries);
204
+ const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
205
+ // A row with no section leaves the mode off, so moving away and back cannot
206
+ // reopen a pane the user closed.
207
+ if (focused && !focused.config) this.json = false;
208
+ const jsonSection = this.json ? focused?.config : undefined;
209
+ const paths = { scope, cwd, agentDir };
210
+ const detail = jsonSection
211
+ ? sectionJsonPane({
212
+ theme,
213
+ width,
214
+ section: jsonSection.section,
215
+ document: surface.sectionDocument(scope, jsonSection.section),
216
+ budget: detailRows(true, viewportRows),
217
+ dirty: surface.isDirty(),
218
+ paths,
219
+ })
220
+ : settingDetail({
221
+ theme,
222
+ width,
223
+ view: focused,
224
+ budget: detailRows(false, viewportRows),
225
+ dirty: surface.isDirty(),
226
+ paths,
227
+ });
228
+
229
+ const { lines, noteShown } = panelFrame({
230
+ theme,
231
+ viewportRows,
232
+ cursor: this.deps.cursor,
233
+ width,
234
+ header,
235
+ summary: sectionBar(theme, width, "settings", settingsSummary(theme, width, scope, views.length)),
236
+ entries,
237
+ note: scope === "project" ? projectNote(theme, width) : undefined,
238
+ detail,
239
+ hint: this.deps.hint(width),
240
+ draw: (entry, index) => {
241
+ if (entry.kind === "group") return groupLine(theme, width, entry);
242
+ if (entry.kind !== "setting") return undefined;
243
+ const editing = this.editing?.id === entry.setting.id ? this.editing : undefined;
244
+ return settingRow(theme, width, entry.setting, index === this.deps.cursor.selected, editing);
245
+ },
246
+ });
247
+ this.deps.setNoteShown(noteShown);
248
+ return lines;
249
+ }
250
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The Skills section: the focused skill's path, description, and refusal.
3
+ *
4
+ * Rows, the summary, and the key hints come from the shared list view, and the
5
+ * row verdict is already `rowBadge`'s. The pane therefore starts with what the
6
+ * row cannot say: the resolved file and what its frontmatter declares, or why Pi
7
+ * refused it. Pure functions over the merged row; the component owns the cursor,
8
+ * the scope, and the expansion.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
13
+ import { sanitizeLine } from "../../../../lib/format.ts";
14
+ import { clip } from "../../../../lib/tui/columns.ts";
15
+ import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
16
+ import { paint } from "../../../../lib/tui/tokens.ts";
17
+ import type { PanelRow } from "../types.ts";
18
+ import { INDENT, savedLine } from "./frame.ts";
19
+
20
+ /** The focused skill's file, description (or refusal), and pending changes. */
21
+ export function skillDetail(options: {
22
+ theme: Theme;
23
+ width: number;
24
+ row: PanelRow | undefined;
25
+ budget: number;
26
+ expanded: boolean;
27
+ dirty: boolean;
28
+ }): string[] {
29
+ const { theme, width, row, budget, expanded, dirty } = options;
30
+ // The focused row above already carries the name and verdict, so the pane
31
+ // starts with the file it resolves to.
32
+ const lines: string[] = [];
33
+ if (row?.item) lines.push(clip(`${INDENT}${paint(theme, "meta", sanitizeLine(row.item.path))}`, width));
34
+ const saved = savedLine(theme, width, dirty);
35
+ const skill = row?.skill;
36
+ const text = skill?.problem !== undefined ? `Not loaded: ${skill.problem}` : skill?.description;
37
+ if (text && budget > 1) {
38
+ // One line while collapsed, the whole wrapped description when expanded,
39
+ // minus the file line already pushed and the saved marker below it.
40
+ const available = Math.max(0, budget - lines.length - saved.length);
41
+ const room = expanded ? available : Math.min(1, available);
42
+ const bodyWidth = Math.max(1, width - BODY_INDENT);
43
+ const role = skill?.problem !== undefined ? "warn" : "meta";
44
+ for (const line of wrapTextWithAnsi(sanitizeLine(text), bodyWidth).slice(0, room)) {
45
+ lines.push(clip(`${INDENT}${paint(theme, role, line)}`, width));
46
+ }
47
+ }
48
+ lines.push(...saved);
49
+ return lines;
50
+ }