@thoth-agents/pi-thoth-theme 0.0.0-stage → 0.1.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 (42) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +115 -2
  3. package/package.json +63 -3
  4. package/src/index.ts +53 -0
  5. package/src/input-box/decorate.ts +304 -0
  6. package/src/input-box/frame.ts +182 -0
  7. package/src/input-box/gradient.ts +92 -0
  8. package/src/input-box/state.ts +90 -0
  9. package/src/render-kit/index.ts +305 -0
  10. package/src/render-kit/working.ts +25 -0
  11. package/src/shared/cache.ts +21 -0
  12. package/src/shared/config.ts +86 -0
  13. package/src/shared/duration.ts +1 -0
  14. package/src/shared/icons.ts +89 -0
  15. package/src/status-line/cost.ts +56 -0
  16. package/src/status-line/index.ts +242 -0
  17. package/src/status-line/layout.ts +272 -0
  18. package/src/status-line/snapshot.ts +11 -0
  19. package/src/status-line/throughput.ts +61 -0
  20. package/src/status-line/tokens.ts +51 -0
  21. package/src/tools/bash.ts +237 -0
  22. package/src/tools/border.ts +21 -0
  23. package/src/tools/box.ts +258 -0
  24. package/src/tools/edit.ts +228 -0
  25. package/src/tools/file-icons.ts +139 -0
  26. package/src/tools/find.ts +227 -0
  27. package/src/tools/frame.ts +166 -0
  28. package/src/tools/generic.ts +195 -0
  29. package/src/tools/grep.ts +371 -0
  30. package/src/tools/index.ts +135 -0
  31. package/src/tools/ls.ts +231 -0
  32. package/src/tools/ownership.ts +70 -0
  33. package/src/tools/powershell.ts +1 -0
  34. package/src/tools/read.ts +194 -0
  35. package/src/tools/ticker.ts +101 -0
  36. package/src/tools/write.ts +188 -0
  37. package/src/welcome/age.ts +11 -0
  38. package/src/welcome/index.ts +132 -0
  39. package/src/welcome/logo.ts +35 -0
  40. package/src/welcome/render.ts +323 -0
  41. package/src/welcome/resources.ts +251 -0
  42. package/themes/thoth.json +104 -0
@@ -0,0 +1,188 @@
1
+ import type {
2
+ AgentToolResult,
3
+ Theme,
4
+ ToolRenderResultOptions,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import { toolFooter } from '../render-kit/index.ts';
7
+ import type { ThemeConfig } from '../shared/config.ts';
8
+ import { icon as semanticIcon, statusIcon } from '../shared/icons.ts';
9
+ import { getToolBorderTone } from './border.ts';
10
+ import { createComponent, getResultText } from './box.ts';
11
+ import { getFileIcon, getToolIcon } from './file-icons.ts';
12
+ import {
13
+ formatDisplayPath,
14
+ hasToolResult,
15
+ renderFrameBottom,
16
+ renderFrameRow,
17
+ renderFrameTop,
18
+ } from './frame.ts';
19
+ import { type ElapsedRenderContext, syncElapsedTicker } from './ticker.ts';
20
+
21
+ const COLLAPSED_WRITE_LINES = 6;
22
+
23
+ interface WriteArgs {
24
+ path?: string;
25
+ file_path?: string;
26
+ content?: string;
27
+ }
28
+
29
+ interface WriteContext extends ElapsedRenderContext {
30
+ args?: unknown;
31
+ cwd?: string;
32
+ lastComponent?: { invalidate?: () => void };
33
+ state?: {
34
+ hasResult?: boolean;
35
+ callComponent?: { invalidate?: () => void };
36
+ [key: string]: unknown;
37
+ };
38
+ }
39
+
40
+ export function createCustomWriteTool(cwd: string, config: ThemeConfig) {
41
+ return {
42
+ renderShell: 'self' as const,
43
+ renderCall(rawArgs: unknown, theme: Theme, context: WriteContext) {
44
+ syncElapsedTicker(context);
45
+ const args = (rawArgs ?? {}) as WriteArgs;
46
+ const rawPath = String(args.path ?? args.file_path ?? '');
47
+ const filePath = formatDisplayPath(rawPath, context?.cwd ?? cwd);
48
+ const icon = getFileIcon(filePath, config.icons);
49
+ const writeIcon = getToolIcon('write', config.icons);
50
+ const borderTone = getToolBorderTone(context);
51
+
52
+ const comp = createComponent((width: number) => {
53
+ const safeWidth = Math.max(0, width);
54
+ const footer =
55
+ context?.executionStarted && context.isPartial
56
+ ? toolFooter(theme, { status: 'running', context }, config.icons)
57
+ : undefined;
58
+ const title = `${theme.fg('accent', writeIcon)} ${theme.bold ? theme.bold(theme.fg('toolTitle', 'Write')) : theme.fg('toolTitle', 'Write')} ${theme.fg('accent', icon)} ${theme.fg('text', filePath)}`;
59
+
60
+ if (hasToolResult(context)) {
61
+ return renderFrameTop(theme, title, safeWidth, borderTone);
62
+ }
63
+
64
+ return [
65
+ ...renderFrameTop(theme, title, safeWidth, borderTone),
66
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
67
+ ];
68
+ });
69
+
70
+ if (context?.state) {
71
+ context.state.callComponent = comp;
72
+ }
73
+ return comp;
74
+ },
75
+
76
+ renderResult(
77
+ result: AgentToolResult<unknown>,
78
+ options: ToolRenderResultOptions,
79
+ theme: Theme,
80
+ context: WriteContext,
81
+ ) {
82
+ if (context?.state) {
83
+ context.state.hasResult = true;
84
+ if (
85
+ context.state.callComponent &&
86
+ typeof context.state.callComponent.invalidate === 'function'
87
+ ) {
88
+ context.state.callComponent.invalidate();
89
+ }
90
+ }
91
+ if (
92
+ context?.lastComponent &&
93
+ typeof context.lastComponent.invalidate === 'function'
94
+ ) {
95
+ context.lastComponent.invalidate();
96
+ }
97
+
98
+ const isErr = Boolean(context?.isError);
99
+ const status = options?.isPartial
100
+ ? 'running'
101
+ : isErr
102
+ ? 'failed'
103
+ : 'completed';
104
+ const footerOptions = {
105
+ status,
106
+ context: { ...context, isPartial: options?.isPartial },
107
+ } as const;
108
+ syncElapsedTicker(footerOptions.context);
109
+ const borderTone = getToolBorderTone({
110
+ isError: isErr,
111
+ isPartial: options?.isPartial,
112
+ });
113
+ const textOutput = getResultText(result);
114
+
115
+ if (isErr) {
116
+ return createComponent((width: number) => {
117
+ const safeWidth = Math.max(0, width);
118
+ const errText = `${theme.fg('error', `${statusIcon('warning', config.icons)} `)}${theme.fg('error', textOutput || 'Write failed')}`;
119
+ return [
120
+ ...renderFrameRow(theme, errText, safeWidth, borderTone),
121
+ ...renderFrameBottom(
122
+ theme,
123
+ toolFooter(theme, footerOptions, config.icons),
124
+ safeWidth,
125
+ borderTone,
126
+ ),
127
+ ];
128
+ });
129
+ }
130
+
131
+ // Check content lines from context args or result
132
+ const writeArgs = context?.args as WriteArgs | undefined;
133
+ const content = String(writeArgs?.content ?? '');
134
+ const contentLines = content ? content.split('\n') : [];
135
+ const lineCount = contentLines.length || 1;
136
+
137
+ return createComponent((width: number) => {
138
+ const safeWidth = Math.max(0, width);
139
+ const addedStr = theme.fg('toolDiffAdded', `+${lineCount} lines`);
140
+ const footer = toolFooter(
141
+ theme,
142
+ {
143
+ ...footerOptions,
144
+ summary: [addedStr, '1 file'],
145
+ },
146
+ config.icons,
147
+ );
148
+
149
+ if (contentLines.length === 0) {
150
+ const defaultMsg = textOutput || 'File written';
151
+ return [
152
+ ...renderFrameRow(
153
+ theme,
154
+ theme.fg('toolOutput', defaultMsg),
155
+ safeWidth,
156
+ borderTone,
157
+ ),
158
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
159
+ ];
160
+ }
161
+
162
+ const visibleLines = options?.expanded
163
+ ? contentLines
164
+ : contentLines.slice(0, COLLAPSED_WRITE_LINES);
165
+ const bodyLines: string[] = visibleLines.map((l) =>
166
+ theme.fg('toolOutput', l),
167
+ );
168
+
169
+ if (!options?.expanded && contentLines.length > COLLAPSED_WRITE_LINES) {
170
+ const remaining = contentLines.length - COLLAPSED_WRITE_LINES;
171
+ bodyLines.push(
172
+ theme.fg(
173
+ 'dim',
174
+ `${semanticIcon('ellipsis', config.icons)} ${remaining} more lines ${semanticIcon('separator', config.icons)} ctrl+o to expand`,
175
+ ),
176
+ );
177
+ }
178
+
179
+ return [
180
+ ...bodyLines.flatMap((l) =>
181
+ renderFrameRow(theme, l, safeWidth, borderTone),
182
+ ),
183
+ ...renderFrameBottom(theme, footer, safeWidth, borderTone),
184
+ ];
185
+ });
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,11 @@
1
+ export function formatAge(fromMs: number, nowMs: number = Date.now()): string {
2
+ const seconds = Math.max(0, Math.round((nowMs - fromMs) / 1000));
3
+ if (seconds < 60) return 'just now';
4
+ const minutes = Math.round(seconds / 60);
5
+ if (minutes < 60) return `${minutes}m ago`;
6
+ const hours = Math.round(minutes / 60);
7
+ if (hours < 24) return `${hours}h ago`;
8
+ const days = Math.round(hours / 24);
9
+ if (days < 30) return `${days}d ago`;
10
+ return `${Math.round(days / 30)}mo ago`;
11
+ }
@@ -0,0 +1,132 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ Theme,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import { VERSION as PI_VERSION } from '@earendil-works/pi-coding-agent';
7
+ import type { Component, TUI } from '@earendil-works/pi-tui';
8
+ import { cachedComponent } from '../shared/cache.ts';
9
+ import type { ThemeConfig } from '../shared/config.ts';
10
+ import { type ActiveThemeLike, renderWelcomeHeader } from './render.ts';
11
+ import {
12
+ collectStartupResources,
13
+ type StartupResourceOptions,
14
+ type WelcomeData,
15
+ } from './resources.ts';
16
+
17
+ export { formatAge } from './age.ts';
18
+ export { getThothLogoLines, THOTH_WORDMARK } from './logo.ts';
19
+ export { type ActiveThemeLike, renderWelcomeHeader } from './render.ts';
20
+ export {
21
+ collectStartupResources,
22
+ fetchRecentSessions,
23
+ type StartupResourceOptions,
24
+ type WelcomeData,
25
+ type WelcomeProvider,
26
+ type WelcomeResourceCounts,
27
+ type WelcomeSession,
28
+ } from './resources.ts';
29
+
30
+ export class WelcomeComponent implements Component {
31
+ private data: WelcomeData;
32
+ private readonly renderCache: Component;
33
+ private _disposed = false;
34
+
35
+ constructor(
36
+ theme: ActiveThemeLike,
37
+ config: ThemeConfig,
38
+ initialData: WelcomeData,
39
+ ) {
40
+ this.data = initialData;
41
+ this.renderCache = cachedComponent((width) =>
42
+ renderWelcomeHeader(theme, this.data, width, config.icons),
43
+ );
44
+ }
45
+
46
+ get isDisposed(): boolean {
47
+ return this._disposed;
48
+ }
49
+
50
+ updateData(newData: WelcomeData): void {
51
+ if (this._disposed) return;
52
+ this.data = newData;
53
+ this.invalidate();
54
+ }
55
+
56
+ invalidate(): void {
57
+ this.renderCache.invalidate();
58
+ }
59
+
60
+ dispose(): void {
61
+ this._disposed = true;
62
+ }
63
+
64
+ render(width: number): string[] {
65
+ return this.renderCache.render(width);
66
+ }
67
+ }
68
+
69
+ export function registerWelcome(
70
+ pi: ExtensionAPI,
71
+ ctx: ExtensionContext,
72
+ config: ThemeConfig,
73
+ options?: StartupResourceOptions,
74
+ ): void {
75
+ if (!ctx.hasUI || !ctx.ui?.setHeader) return;
76
+
77
+ const initialData: WelcomeData = {
78
+ version: PI_VERSION,
79
+ model: ctx.model?.name || ctx.model?.id,
80
+ provider: ctx.model?.provider,
81
+ resources: {
82
+ tools:
83
+ typeof pi.getAllTools === 'function'
84
+ ? (pi.getAllTools()?.length ?? 0)
85
+ : typeof pi.getActiveTools === 'function'
86
+ ? (pi.getActiveTools()?.length ?? 0)
87
+ : undefined,
88
+ skills:
89
+ typeof pi.getCommands === 'function'
90
+ ? (pi
91
+ .getCommands()
92
+ ?.filter(
93
+ (c) =>
94
+ c?.source === 'skill' ||
95
+ (typeof c?.name === 'string' && c.name.startsWith('skill:')),
96
+ )?.length ?? undefined)
97
+ : undefined,
98
+ extensions: undefined,
99
+ },
100
+ providers: [],
101
+ sessions: [],
102
+ };
103
+
104
+ let activeComponent: WelcomeComponent | undefined;
105
+
106
+ ctx.ui.setHeader((tui: TUI, theme: Theme) => {
107
+ if (activeComponent && !activeComponent.isDisposed) {
108
+ activeComponent.dispose();
109
+ }
110
+
111
+ const activeTheme = theme as unknown as ActiveThemeLike;
112
+ const component = new WelcomeComponent(activeTheme, config, initialData);
113
+ activeComponent = component;
114
+
115
+ // Asynchronously populate full resource details and recent sessions
116
+ void collectStartupResources(pi, ctx, {
117
+ ...options,
118
+ iconMode: options?.iconMode ?? config.icons,
119
+ })
120
+ .then((fullData) => {
121
+ if (!component.isDisposed && activeComponent === component) {
122
+ component.updateData(fullData);
123
+ tui?.requestRender?.();
124
+ }
125
+ })
126
+ .catch(() => {
127
+ // Resource collection is best-effort and must never break startup
128
+ });
129
+
130
+ return component;
131
+ });
132
+ }
@@ -0,0 +1,35 @@
1
+ import type { IconMode } from '../shared/config.ts';
2
+
3
+ export const THOTH_WORDMARK = 'T H O T H';
4
+
5
+ export const THOTH_WORDMARK_BOX = [
6
+ '╔╦╗╦ ╦╔═╗╔╦╗╦ ╦',
7
+ ' ║ ╠═╣║ ║ ║ ╠═╣',
8
+ ' ╩ ╩ ╩╚═╝ ╩ ╩ ╩',
9
+ ] as const;
10
+
11
+ export const THOTH_LOGO_NERD = [
12
+ ' ▄▄██████▄▄ ',
13
+ ' ▄██▀▀ ▀▀██▄ ',
14
+ ' ███ ▄████▄ ███ ',
15
+ ' ▐██▌ ▐██████▌ ▐██▌ ',
16
+ ' ███ ▀████▀ ███ ',
17
+ ' ▀██▄▄ ▀▀ ▄▄██▀ ',
18
+ ' ▀▀██████▀▀ ',
19
+ ' ██ ▀▄ ',
20
+ ' ██ ▀▄ ',
21
+ ] as const;
22
+
23
+ export const THOTH_LOGO_ASCII = [
24
+ ' .-------. ',
25
+ ' / .---. \\ ',
26
+ " ,-' ( (o) ) `-. ",
27
+ " <____ `---' ____>",
28
+ " `-------' ",
29
+ ' | \\ ',
30
+ ' | \\ ',
31
+ ] as const;
32
+
33
+ export function getThothLogoLines(mode: IconMode): readonly string[] {
34
+ return mode === 'ascii' ? THOTH_LOGO_ASCII : THOTH_LOGO_NERD;
35
+ }
@@ -0,0 +1,323 @@
1
+ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
+ import type { IconMode } from '../shared/config.ts';
3
+ import { icon } from '../shared/icons.ts';
4
+ import { getThothLogoLines } from './logo.ts';
5
+ import type {
6
+ WelcomeData,
7
+ WelcomeProvider,
8
+ WelcomeSession,
9
+ } from './resources.ts';
10
+
11
+ export interface ActiveThemeLike {
12
+ fg?: (color: string, text: string) => string;
13
+ bold?: (text: string) => string;
14
+ }
15
+
16
+ function color(
17
+ theme: ActiveThemeLike | undefined,
18
+ token: string,
19
+ text: string,
20
+ ): string {
21
+ if (!text) return '';
22
+ if (theme?.fg) {
23
+ try {
24
+ return theme.fg(token, text);
25
+ } catch {
26
+ return text;
27
+ }
28
+ }
29
+ return text;
30
+ }
31
+
32
+ function bold(theme: ActiveThemeLike | undefined, text: string): string {
33
+ if (!text) return '';
34
+ if (theme?.bold) {
35
+ try {
36
+ return theme.bold(text);
37
+ } catch {
38
+ return text;
39
+ }
40
+ }
41
+ return text;
42
+ }
43
+
44
+ function separator(mode: IconMode): string {
45
+ const pad = mode === 'ascii' ? ' ' : ' ';
46
+ return `${pad}${icon('separator', mode)}${pad}`;
47
+ }
48
+
49
+ function padEnd(text: string, width: number, ellipsis: string): string {
50
+ const current = visibleWidth(text);
51
+ if (current > width) return truncateToWidth(text, width, ellipsis);
52
+ return text + ' '.repeat(Math.max(0, width - current));
53
+ }
54
+
55
+ function center(text: string, width: number, ellipsis: string): string {
56
+ const current = visibleWidth(text);
57
+ if (current > width) return truncateToWidth(text, width, ellipsis);
58
+ const left = Math.floor((width - current) / 2);
59
+ const right = width - current - left;
60
+ return ' '.repeat(left) + text + ' '.repeat(right);
61
+ }
62
+
63
+ function formatResourceSummary(
64
+ theme: ActiveThemeLike | undefined,
65
+ resources: WelcomeData['resources'],
66
+ sep: string,
67
+ ): string {
68
+ const parts: string[] = [];
69
+ const cText = (s: string) => color(theme, 'text', s);
70
+ const cSuccess = (s: string) => color(theme, 'success', s);
71
+ const cDim = (s: string) => color(theme, 'dim', s);
72
+
73
+ if (typeof resources.tools === 'number') {
74
+ parts.push(cText('tools ') + cSuccess(String(resources.tools)));
75
+ }
76
+ if (typeof resources.skills === 'number') {
77
+ parts.push(cText('skills ') + cSuccess(String(resources.skills)));
78
+ }
79
+ if (typeof resources.extensions === 'number') {
80
+ parts.push(cText('exts ') + cSuccess(String(resources.extensions)));
81
+ }
82
+
83
+ return parts.join(cDim(sep));
84
+ }
85
+
86
+ function buildTips(
87
+ theme: ActiveThemeLike | undefined,
88
+ mode: IconMode,
89
+ ): string[] {
90
+ const bullet = mode === 'ascii' ? '*' : '•';
91
+ const sep = separator(mode);
92
+ const cDim = (s: string) => color(theme, 'dim', s);
93
+ const cMuted = (s: string) => color(theme, 'muted', s);
94
+ return [
95
+ ` ${cDim(bullet)} ${cMuted('/ for commands')}${cDim(sep)}${cMuted('! for bash')}`,
96
+ ];
97
+ }
98
+
99
+ function buildProviderRows(
100
+ theme: ActiveThemeLike | undefined,
101
+ providers: readonly WelcomeProvider[],
102
+ mode: IconMode,
103
+ maxWidth: number,
104
+ ): string[] {
105
+ const bullet = mode === 'ascii' ? '*' : '●';
106
+ const cSuccess = (s: string) => color(theme, 'success', s);
107
+ const cMuted = (s: string) => color(theme, 'muted', s);
108
+ const cDim = (s: string) => color(theme, 'dim', s);
109
+
110
+ if (providers.length === 0) {
111
+ return [` ${cDim('No tool providers')}`];
112
+ }
113
+
114
+ return providers.slice(0, 4).map((p) => {
115
+ const mark = cSuccess(bullet);
116
+ const name = cMuted(p.name);
117
+ const prefixWidth = visibleWidth(` ${bullet} ${p.name}: `);
118
+ const detailWidth = Math.max(1, maxWidth - prefixWidth);
119
+ const detail = cDim(
120
+ truncateToWidth(p.detail, detailWidth, icon('ellipsis', mode)),
121
+ );
122
+ return ` ${mark} ${name} ${detail}`;
123
+ });
124
+ }
125
+
126
+ function buildSessionRows(
127
+ theme: ActiveThemeLike | undefined,
128
+ sessions: readonly WelcomeSession[],
129
+ mode: IconMode,
130
+ maxWidth: number,
131
+ ): string[] {
132
+ const bullet = mode === 'ascii' ? '*' : '•';
133
+ const cDim = (s: string) => color(theme, 'dim', s);
134
+ const cMuted = (s: string) => color(theme, 'muted', s);
135
+
136
+ if (sessions.length === 0) {
137
+ return [` ${cDim('No recent sessions')}`];
138
+ }
139
+
140
+ return sessions.slice(0, 4).map((s) => {
141
+ const age = ` (${s.timeAgo})`;
142
+ const ageWidth = visibleWidth(age);
143
+ const bulletPrefix = ` ${bullet} `;
144
+ const maxNameWidth = Math.max(
145
+ 1,
146
+ maxWidth - visibleWidth(bulletPrefix) - ageWidth,
147
+ );
148
+ const name = cMuted(
149
+ truncateToWidth(s.name, maxNameWidth, icon('ellipsis', mode)),
150
+ );
151
+ return ` ${cDim(bullet)} ${name}${cDim(age)}`;
152
+ });
153
+ }
154
+
155
+ export function renderWelcomeHeader(
156
+ theme: ActiveThemeLike | undefined,
157
+ data: WelcomeData,
158
+ width: number,
159
+ mode: IconMode = 'nerd',
160
+ ): string[] {
161
+ if (width <= 0) return [];
162
+
163
+ const isAscii = mode === 'ascii';
164
+ const cAccent = (s: string) => color(theme, 'accent', s);
165
+ const cDim = (s: string) => color(theme, 'dim', s);
166
+ const cMuted = (s: string) => color(theme, 'muted', s);
167
+ const cText = (s: string) => color(theme, 'text', s);
168
+
169
+ const bTl = isAscii ? '+' : '╭';
170
+ const bTr = isAscii ? '+' : '╮';
171
+ const bBl = isAscii ? '+' : '╰';
172
+ const bBr = isAscii ? '+' : '╯';
173
+ const bV = isAscii ? '|' : '│';
174
+ const bH = isAscii ? '-' : '─';
175
+ const sep = separator(mode);
176
+ const ellipsis = icon('ellipsis', mode);
177
+ const mark = isAscii ? '*' : '◆';
178
+
179
+ // Wide layout (2 columns)
180
+ if (width >= 64) {
181
+ const boxWidth = width;
182
+ const contentWidth = boxWidth - 3; // left bV, middle bV, right bV
183
+ const logoLines = getThothLogoLines(mode);
184
+ const logoWidth = Math.max(...logoLines.map((l) => visibleWidth(l)));
185
+ const leftColumn = Math.max(logoWidth + 2, 24);
186
+ const rightColumn = contentWidth - leftColumn;
187
+
188
+ if (rightColumn >= 30) {
189
+ // Build left column
190
+ const leftRows: string[] = [
191
+ '',
192
+ center(bold(theme, cAccent('T H O T H')), leftColumn, ellipsis),
193
+ '',
194
+ ...logoLines.map((line) => center(cAccent(line), leftColumn, ellipsis)),
195
+ '',
196
+ center(cText(data.model || 'pi-coding-agent'), leftColumn, ellipsis),
197
+ center(cDim(data.provider || ''), leftColumn, ellipsis),
198
+ ];
199
+
200
+ // Build right column
201
+ const resourceSummary = formatResourceSummary(theme, data.resources, sep);
202
+ const rightDivider = ` ${cDim(bH.repeat(Math.max(1, rightColumn - 2)))}`;
203
+ const resourceHeader = resourceSummary
204
+ ? ` ${cAccent(mark)} ${bold(theme, cAccent('Resources'))} ${resourceSummary}`
205
+ : ` ${cAccent(mark)} ${bold(theme, cAccent('Resources'))}`;
206
+
207
+ const rightRows: string[] = [
208
+ resourceHeader,
209
+ rightDivider,
210
+ ` ${cAccent(mark)} ${bold(theme, cAccent('Tool Providers'))}`,
211
+ ...buildProviderRows(theme, data.providers, mode, rightColumn),
212
+ rightDivider,
213
+ ` ${cAccent(mark)} ${bold(theme, cAccent('Recent Sessions'))}`,
214
+ ...buildSessionRows(theme, data.sessions, mode, rightColumn),
215
+ rightDivider,
216
+ ...buildTips(theme, mode),
217
+ ];
218
+
219
+ const maxRows = Math.max(leftRows.length, rightRows.length);
220
+ while (leftRows.length < maxRows) leftRows.push('');
221
+ while (rightRows.length < maxRows) rightRows.push('');
222
+
223
+ const label = ` v${data.version} `;
224
+ const lead = bH.repeat(3);
225
+ const remainingFill = Math.max(
226
+ 0,
227
+ boxWidth - 2 - visibleWidth(lead) - visibleWidth(label),
228
+ );
229
+ const topBorder = `${cDim(bTl)}${cDim(lead)}${cAccent(label)}${cDim(bH.repeat(remainingFill))}${cDim(bTr)}`;
230
+ const bottomBorder = `${cDim(bBl)}${cDim(bH.repeat(boxWidth - 2))}${cDim(bBr)}`;
231
+
232
+ const out: string[] = [topBorder];
233
+ for (let i = 0; i < maxRows; i++) {
234
+ const leftCell = padEnd(leftRows[i] ?? '', leftColumn, ellipsis);
235
+ const rightCell = padEnd(rightRows[i] ?? '', rightColumn, ellipsis);
236
+ out.push(`${cDim(bV)}${leftCell}${cDim(bV)}${rightCell}${cDim(bV)}`);
237
+ }
238
+ out.push(bottomBorder);
239
+
240
+ return out.map((line) =>
241
+ visibleWidth(line) <= width
242
+ ? line
243
+ : truncateToWidth(line, width, ellipsis),
244
+ );
245
+ }
246
+ }
247
+
248
+ // Medium layout (single column boxed)
249
+ if (width >= 42) {
250
+ const boxWidth = width;
251
+ const innerWidth = boxWidth - 2;
252
+ const label = ` Thoth v${data.version} `;
253
+ const lead = bH.repeat(2);
254
+ const remainingFill = Math.max(
255
+ 0,
256
+ boxWidth - 2 - visibleWidth(lead) - visibleWidth(label),
257
+ );
258
+ const topBorder = `${cDim(bTl)}${cDim(lead)}${cAccent(label)}${cDim(bH.repeat(remainingFill))}${cDim(bTr)}`;
259
+ const bottomBorder = `${cDim(bBl)}${cDim(bH.repeat(boxWidth - 2))}${cDim(bBr)}`;
260
+
261
+ const divider = `${cDim(bV)} ${cDim(bH.repeat(Math.max(1, innerWidth - 2)))} ${cDim(bV)}`;
262
+
263
+ const resourceSummary = formatResourceSummary(theme, data.resources, sep);
264
+
265
+ const content: string[] = [
266
+ center(bold(theme, cAccent('T H O T H')), innerWidth, ellipsis),
267
+ center(cText(data.model || 'pi-coding-agent'), innerWidth, ellipsis),
268
+ divider,
269
+ ...(resourceSummary
270
+ ? [` ${cAccent(mark)} ${resourceSummary}`, divider]
271
+ : []),
272
+ ` ${cAccent(mark)} ${bold(theme, cAccent('Recent Sessions'))}`,
273
+ ...buildSessionRows(theme, data.sessions, mode, innerWidth),
274
+ ];
275
+
276
+ const out: string[] = [topBorder];
277
+ for (const row of content) {
278
+ if (row === divider) {
279
+ out.push(divider);
280
+ } else {
281
+ out.push(`${cDim(bV)}${padEnd(row, innerWidth, ellipsis)}${cDim(bV)}`);
282
+ }
283
+ }
284
+ out.push(bottomBorder);
285
+
286
+ return out.map((line) =>
287
+ visibleWidth(line) <= width
288
+ ? line
289
+ : truncateToWidth(line, width, ellipsis),
290
+ );
291
+ }
292
+
293
+ // Narrow layout (minimal text lines without outer box)
294
+ const lines: string[] = [
295
+ cAccent(bold(theme, `Thoth v${data.version}`)),
296
+ cText(truncateToWidth(data.model || 'ready', width, ellipsis)),
297
+ ];
298
+
299
+ const narrowParts: string[] = [];
300
+ if (typeof data.resources.tools === 'number') {
301
+ narrowParts.push(`tools:${data.resources.tools}`);
302
+ }
303
+ if (typeof data.resources.skills === 'number') {
304
+ narrowParts.push(`skills:${data.resources.skills}`);
305
+ }
306
+ if (typeof data.resources.extensions === 'number') {
307
+ narrowParts.push(`exts:${data.resources.extensions}`);
308
+ }
309
+ if (narrowParts.length > 0) {
310
+ lines.push(cDim(narrowParts.join(' ')));
311
+ }
312
+
313
+ if (data.sessions.length > 0) {
314
+ const s = data.sessions[0];
315
+ if (s) {
316
+ lines.push(cMuted(truncateToWidth(`* ${s.name}`, width, ellipsis)));
317
+ }
318
+ }
319
+
320
+ return lines.map((line) =>
321
+ visibleWidth(line) <= width ? line : truncateToWidth(line, width, ellipsis),
322
+ );
323
+ }