ispbills-icli 8.4.12 → 8.4.13

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.
@@ -1,8 +1,8 @@
1
1
  // Presentational components for the iCli Ink TUI.
2
- import { html, useState, useEffect, useRef, useCallback } from './dom.js';
2
+ import { html, useState, useEffect } from './dom.js';
3
3
  import { Box, Text } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
- import { C, SG, glyphs, spinnerType, borderStyle, midDot, dash, asciiSafe } from './theme.js';
5
+ import { C, glyphs, spinnerType, borderStyle, midDot, dash } from './theme.js';
6
6
  import { Markdown } from './markdown.js';
7
7
  import { shortModel } from '../ui.js';
8
8
 
@@ -15,26 +15,13 @@ const LOGO = [
15
15
  '╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
16
16
  ];
17
17
 
18
- // Width of the widest logo line (used for the separator beneath).
19
- const LOGO_WIDTH = Math.max(...LOGO.map((l) => l.length));
20
-
21
- /** Thin separator that spans the full logo width (or less on narrow terminals). */
22
- function LogoRule({ width }) {
23
- const w = Math.min(width, LOGO_WIDTH + 4);
24
- return html`<${Text} dimColor>${(asciiSafe() ? '-' : '─').repeat(Math.max(8, w))}<//>`;
25
- }
26
-
27
18
  /** Header banner — full logo on wide/tall terminals, compact title otherwise. */
28
- export function Banner({ cfg = {}, model, width = 80, compact = false, version = '', cwd = '' }) {
19
+ export function Banner({ cfg = {}, model, width = 80, compact = false }) {
29
20
  const m = shortModel(model || cfg._model || 'IspBills AI');
30
21
  const wide = !compact && width >= 60;
31
22
  const meta = [
32
- ['model', m],
33
- ['user', cfg.user?.name],
34
- ['role', cfg.user?.role],
35
- version ? ['v', version] : null,
36
- cwd ? ['cwd', cwd] : null,
37
- ].filter(Boolean).filter(([, v]) => v);
23
+ ['model', m], ['user', cfg.user?.name], ['role', cfg.user?.role],
24
+ ].filter(([, v]) => v);
38
25
 
39
26
  if (compact) {
40
27
  return html`
@@ -43,42 +30,28 @@ export function Banner({ cfg = {}, model, width = 80, compact = false, version =
43
30
  ${meta.map(([label, val], i) => html`
44
31
  <${Box} key=${'m' + i}>
45
32
  <${Text} color=${C.gray}>${midDot()}<//>
46
- <${Text} color=${label === 'model' ? C.cyan : label === 'v' ? C.gray : C.white}>${val}<//>
33
+ <${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
47
34
  <//>`)}
48
35
  <//>`;
49
36
  }
50
37
 
51
38
  return html`
52
- <${Box} flexDirection="column" marginBottom=${0} alignItems="center">
39
+ <${Box} flexDirection="column" marginBottom=${1} alignItems="center">
53
40
  ${wide
54
41
  ? html`<${Box} flexDirection="column" alignItems="center">
55
42
  ${LOGO.map((l, i) => html`<${Text} key=${'l' + i} color=${C.accent} bold>${l}<//>`)}
56
- <${Text} dimColor>iCopilot ${dash()} network engineer in your terminal<//>
43
+ <${Text} color=${C.gray}>iCopilot ${dash()} network engineer in your terminal<//>
57
44
  <//>`
58
45
  : html`<${Text} color=${C.accent} bold>iCopilot ${html`<${Text} color=${C.gray}>${dash()} IspBills AI terminal<//>`}<//>`}
59
46
  <${Box} marginTop=${1}>
60
47
  ${meta.map(([label, val], i) => html`
61
48
  <${Box} key=${'m' + i}>
62
49
  ${i > 0 ? html`<${Text} color=${C.gray}>${midDot()}<//>` : null}
63
- <${Text} dimColor>${label} <//>
64
- <${Text} color=${
65
- label === 'model' ? C.cyan :
66
- label === 'v' ? C.gray :
67
- label === 'cwd' ? C.yellow :
68
- C.white}>${val}<//>
50
+ <${Text} color=${C.gray}>${label} <//>
51
+ <${Text} color=${label === 'model' ? C.cyan : C.white}>${val}<//>
69
52
  <//>`)}
70
53
  <//>
71
- ${cfg.url ? html`<${Box} marginTop=${0}><${Text} dimColor>${cfg.url}<//><//>` : null}
72
- <${Box} marginTop=${1}>
73
- <${Text} dimColor>Type a message${midDot()}<//>
74
- <${Text} color=${C.accent}>/help<//>
75
- <${Text} dimColor> for commands${midDot()}<//>
76
- <${Text} color=${C.orange}>!<//>
77
- <${Text} dimColor> to run shell<//>
78
- <//>
79
- <${Box} marginTop=${1}>
80
- <${LogoRule} width=${width} />
81
- <//>
54
+ ${cfg.url ? html`<${Text} color=${C.gray}>${cfg.url}<//>` : null}
82
55
  <//>`;
83
56
  }
84
57
 
@@ -95,20 +68,17 @@ function withMentions(text, base = C.white) {
95
68
  /** The user's prompt line. */
96
69
  export function UserMessage({ text }) {
97
70
  return html`
98
- <${Box} marginTop=${1} marginBottom=${0}>
71
+ <${Box} marginTop=${1}>
99
72
  <${Text} color=${C.accent} bold>${glyphs.prompt} <//>
100
- <${Box} flexShrink=${1}>
101
- <${Text} bold>${withMentions(text)}<//>
102
- <//>
73
+ <${Text}>${withMentions(text)}<//>
103
74
  <//>`;
104
75
  }
105
76
 
106
- /** A finished assistant answer (markdown). Optionally shows a streaming cursor. */
107
- export function AssistantMessage({ text, streaming = false }) {
77
+ /** A finished assistant answer (markdown). */
78
+ export function AssistantMessage({ text }) {
108
79
  return html`
109
80
  <${Box} flexDirection="column" marginTop=${1}>
110
81
  <${Markdown} content=${text} />
111
- ${streaming ? html`<${Text} color="cyan">▌<//>` : null}
112
82
  <//>`;
113
83
  }
114
84
 
@@ -118,248 +88,101 @@ const SUBAGENT = /^\s*[✓✗x]?\s*\[(\d+)\/(\d+)\]\s*(.+?)\s*[::]\s*(.+?)\s*$
118
88
  // A leading emoji/pictograph on a tool status label (from statusLabel()).
119
89
  const LEAD_EMOJI = /^\s*([\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}\u{FE0F}\u{2049}\u{203C}]+)\s*/u;
120
90
 
121
- // Max tool-groups shown before collapsing older ones (GitHub Copilot CLI style).
122
- const MAX_VISIBLE_GROUPS = 5;
123
-
124
- // Max sub-agent rows per group before collapsing earlier agents.
125
- const MAX_SUBAGENT_ROWS = 8;
126
-
127
- /**
128
- * Group consecutive SUBAGENT lines under their preceding tool line.
129
- * Returns [{ tool: string|null, subagents: string[] }]
130
- */
131
- function groupStatusLines(lines) {
132
- const groups = [];
133
- let cur = null;
134
- for (const line of lines) {
135
- if (SUBAGENT.test(String(line))) {
136
- if (!cur) { cur = { tool: null, subagents: [] }; groups.push(cur); }
137
- cur.subagents.push(line);
138
- } else {
139
- cur = { tool: String(line), subagents: [] };
140
- groups.push(cur);
141
- }
142
- }
143
- return groups;
144
- }
145
-
146
- /**
147
- * Parse a tool status string into { em, action, detail }.
148
- * Strips leading emoji/glyphs and splits on the first ':'.
149
- */
150
- function parseTool(line) {
151
- const em = String(line).match(LEAD_EMOJI);
152
- const body = em
153
- ? String(line).slice(em[0].length)
154
- : String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
155
- const ci = body.indexOf(':');
156
- return {
157
- em: em ? em[1] : null,
158
- action: ci > -1 ? body.slice(0, ci).trim() : body.trim(),
159
- detail: ci > -1 ? body.slice(ci + 1).trim() : '',
160
- };
161
- }
162
-
163
91
  /**
164
- * Render a single tool activity line.
165
- * running=true accent spinner + white bold text.
166
- * done dim + dim text (GitHub Copilot CLI completed-step style).
167
- * extraLabel appended dim text (used for sub-agent progress badge "(N/M)").
168
- */
169
- function toolLine(line, running, gi, extraLabel) {
170
- const { em, action, detail } = parseTool(line);
171
- return html`
172
- <${Box} key=${'tl' + gi}>
173
- ${running
174
- ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
175
- : html`<${Text} dimColor>${SG.filled}<//>` }
176
- <${Text}> <//>
177
- ${em ? html`<${Text}>${em} <//>` : null}
178
- <${Text} color=${running ? C.white : void 0} dimColor=${!running} bold=${running}>${action}<//>
179
- ${detail
180
- ? html`<${Text} dimColor>: <//>
181
- <${Text} dimColor>${detail}<//>` : null}
182
- ${extraLabel ? html`<${Text} dimColor> ${extraLabel}<//>` : null}
183
- <//>`;
184
- }
185
-
186
- /**
187
- * Render a group that has sub-agent rows nested inside it.
188
- *
189
- * GitHub Copilot CLI fleet/sub-agent style:
190
- * ● spawn_subagent: fleet check (8/8) ← parent tool, progress badge
191
- * ├─ 1/8 olt#1 ✓ 5 ONUs online ← completed OK (dim)
192
- * ├─ 2/8 nas#2 ✗ unreachable ← completed ERR (dim red)
193
- * │ 3/8 router#1 ⠙ checking… ← in-flight (│ pipe)
194
- * └─ 4/8 switch#1 ✓ all OK ← last done (└─)
195
- */
196
- function subagentGroup(group, groupRunning, gi) {
197
- const { tool, subagents } = group;
198
-
199
- // Cap visible sub-agents — collapse older ones to "N earlier agents".
200
- const visibleSubs = subagents.slice(-MAX_SUBAGENT_ROWS);
201
- const hiddenSubs = subagents.length - visibleSubs.length;
202
-
203
- // Determine total from last sub-agent line.
204
- const lastSm = SUBAGENT.exec(visibleSubs[visibleSubs.length - 1] || '');
205
- const total = lastSm ? Number(lastSm[2]) : subagents.length;
206
-
207
- // Count completed agents for progress badge.
208
- const doneCount = subagents.filter(l => {
209
- const m = SUBAGENT.exec(l);
210
- return m && !/checking|running|pending/i.test(m[4]);
211
- }).length;
212
-
213
- // Is the last visible sub-agent still running?
214
- const lastVisIdx = visibleSubs.length - 1;
215
- const lastSmCheck = SUBAGENT.exec(visibleSubs[lastVisIdx] || '');
216
- const lastRunning = groupRunning && lastSmCheck
217
- ? Number(lastSmCheck[1]) < Number(lastSmCheck[2])
218
- : groupRunning;
219
-
220
- // Progress badge on parent: "(3/8)" while running, "(8/8 done)" when complete.
221
- const badge = lastRunning
222
- ? `(${doneCount}/${total})`
223
- : `(${total}/${total} ${glyphs.check})`;
224
-
225
- // Column-pad host names within this group for alignment.
226
- const hosts = visibleSubs.map(l => (SUBAGENT.exec(l)?.[3] || '').trim());
227
- const padW = Math.min(Math.max(...hosts.map(h => h.length), 6), 20);
228
-
229
- // Width for idx/total fraction display (right-align idx).
230
- const totLen = String(total).length;
231
-
232
- return html`
233
- <${Box} key=${'sg' + gi} flexDirection="column">
234
- ${tool ? toolLine(tool, groupRunning && lastRunning, gi, badge) : null}
235
- ${hiddenSubs > 0
236
- ? html`<${Box} key="hid">
237
- <${Text} dimColor> ${glyphs.vbar} <//>
238
- <${Text} dimColor>${glyphs.bullet} ${hiddenSubs} earlier agent${hiddenSubs > 1 ? 's' : ''} ${dash()} collapsed<//>
239
- <//>` : null}
240
- ${visibleSubs.map((line, si) => {
241
- const sm = SUBAGENT.exec(line);
242
- if (!sm) return null;
243
- const [, idx, tot, host, result] = sm;
244
- const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
245
- const isLast = si === lastVisIdx;
246
- const isRunning = groupRunning && isLast && Number(idx) < Number(tot);
247
-
248
- // Tree marker: │ (pipe) for in-flight, └─ for last done, ├─ for others.
249
- const isFinalEntry = Number(idx) === Number(tot) || (isLast && !isRunning);
250
- const branch = isRunning
251
- ? glyphs.vbar + ' ' // "│ " in-flight
252
- : (isFinalEntry ? glyphs.branchEnd + ' ' : glyphs.branchMid + ' '); // "└─ " or "├─ "
253
-
254
- // Right-align idx within the fraction, e.g. " 3/8" vs "12/12".
255
- const frac = String(idx).padStart(totLen) + '/' + tot;
256
- const hostPad = host.padEnd(padW);
257
-
258
- return html`
259
- <${Box} key=${'sa' + si}>
260
- <${Text} color=${isRunning ? C.accent : void 0} dimColor=${!isRunning}> ${branch}<//>
261
- <${Text} color=${isRunning ? C.cyan : void 0} dimColor=${!isRunning}>${frac} <//>
262
- <${Text} color=${isRunning ? C.white : void 0} dimColor=${!isRunning}>${hostPad} <//>
263
- ${isRunning
264
- ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
265
- <${Text} color=${C.accent}> checking…<//>`
266
- : html`<${Text} color=${good ? C.green : C.red} dimColor> ${good ? glyphs.check : glyphs.cross}<//>
267
- <${Text} color=${good ? C.green : C.red} dimColor> ${result}<//>` }
268
- <//>`;
269
- })}
270
- <//>`;
271
- }
272
-
273
- /**
274
- * Live server activity — tool calls and sub-agents, rendered Copilot-CLI style.
275
- *
276
- * Tool groups are the primary unit: each tool line is rendered with a spinner
277
- * (in-flight) or dim ● (done). Sub-agent blocks are nested tree-style under
278
- * their parent tool with ├─/└─/│ markers, padded columns, and a progress
279
- * badge on the parent line — matching GitHub Copilot CLI fleet/sub-agent UI.
280
- *
281
- * Old groups collapse to a summary count so the live area stays compact.
92
+ * Live server activity tool calls and sub-agents, rendered Copilot-CLI style:
93
+ * each activity gets a status marker (spinner while it's the in-flight step,
94
+ * green check once done), the tool's purpose label is kept, and sub-agent
95
+ * progress is nested underneath its parent as a tree.
282
96
  */
283
97
  export function StatusLines({ lines = [], busy = false }) {
284
98
  if (!lines.length) return null;
285
-
286
- const groups = groupStatusLines(lines);
287
-
288
- // Collapse old groups — keep last MAX_VISIBLE_GROUPS.
289
- const hiddenGroupCount = groups.length > MAX_VISIBLE_GROUPS
290
- ? groups.length - MAX_VISIBLE_GROUPS : 0;
291
- const visibleGroups = groups.slice(hiddenGroupCount);
292
-
293
- // Total lines in hidden groups (for the summary count).
294
- const hiddenLineCount = groups
295
- .slice(0, hiddenGroupCount)
296
- .reduce((acc, g) => acc + 1 + g.subagents.length, 0);
297
-
298
- const lastGi = visibleGroups.length - 1;
299
-
99
+ const subLines = lines.filter((l) => SUBAGENT.test(l));
100
+ const lastIdx = lines.length - 1;
300
101
  return html`
301
- <${Box} flexDirection="column" marginTop=${0}>
302
- ${hiddenLineCount > 0
303
- ? html`<${Box} key="coll">
304
- <${Text} dimColor>${SG.filled} ${hiddenLineCount} earlier step${hiddenLineCount > 1 ? 's' : ''} ${dash()} collapsed<//>
305
- <//>` : null}
306
- ${visibleGroups.map((g, gi) => {
307
- const isLast = gi === lastGi;
308
- const groupRunning = busy && isLast;
309
- if (g.subagents.length > 0) return subagentGroup(g, groupRunning, gi);
310
- return toolLine(g.tool || '', groupRunning, gi, null);
102
+ <${Box} flexDirection="column" marginTop=${1}>
103
+ ${lines.map((line, i) => {
104
+ const sm = String(line).match(SUBAGENT);
105
+ if (sm) {
106
+ const [, idx, total, host, result] = sm;
107
+ const good = !/fail|error|down|offline|unreachable|timeout|deny/i.test(result);
108
+ const isLast = subLines[subLines.length - 1] === line;
109
+ const running = busy && isLast && Number(idx) < Number(total);
110
+ const branch = Number(idx) === Number(total) ? glyphs.branchEnd : glyphs.branchMid;
111
+ return html`
112
+ <${Box} key=${'s' + i}>
113
+ <${Text} color=${C.gray}> <//>
114
+ <${Text} color=${C.magenta}>${branch} <//>
115
+ <${Text} color=${C.gray}>${idx}/${total} <//>
116
+ <${Text} color=${C.white} bold>${host}<//>
117
+ <${Text} color=${C.gray}> ${dash()} <//>
118
+ ${running
119
+ ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /> working<//>`
120
+ : html`<${Text} color=${good ? C.green : C.red} bold>${good ? glyphs.check : glyphs.cross} ${result}<//>`}
121
+ <//>`;
122
+ }
123
+
124
+ // Tool / activity line. The last line while busy is the in-flight step.
125
+ const running = busy && i === lastIdx;
126
+ const em = String(line).match(LEAD_EMOJI);
127
+ const body = em ? String(line).slice(em[0].length) : String(line).replace(/^[\s◈◆●○◎⇢→>*✓✗×!⚠·-]+/u, '');
128
+ const ci = body.indexOf(':');
129
+ const action = ci > -1 ? body.slice(0, ci) : body;
130
+ const detail = ci > -1 ? body.slice(ci + 1).trim() : '';
131
+ return html`
132
+ <${Box} key=${'s' + i}>
133
+ ${running
134
+ ? html`<${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>`
135
+ : html`<${Text} color=${C.green}>${glyphs.check}<//>`}
136
+ <${Text}> <//>
137
+ ${em ? html`<${Text}>${em[1]} <//>` : null}
138
+ <${Text} color=${running ? C.white : C.gray} bold=${running}>${action}<//>
139
+ ${detail ? html`<${Text} color=${C.gray}>: ${detail}<//>` : null}
140
+ <//>`;
311
141
  })}
312
142
  <//>`;
313
143
  }
314
144
 
315
- /** Reasoning / thinking trace (shown by default; toggle with Ctrl+T). */
145
+ /** Reasoning trace (shown by default; toggle with Ctrl+T). */
316
146
  export function Reasoning({ text }) {
317
147
  if (!text) return null;
318
148
  return html`
319
- <${Box} flexDirection="column" marginTop=${1}
320
- borderStyle=${borderStyle()} borderColor=${C.magenta}
321
- borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
322
- <${Text} color=${C.magenta} bold>◆ Thinking<//>
323
- <${Text} dimColor italic>${text}<//>
149
+ <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.gray}
150
+ borderTop=${false} borderRight=${false} borderBottom=${false} paddingLeft=${1}>
151
+ <${Text} color=${C.magenta}>${glyphs.reasoning} ${html`<${Text} color=${C.gray} bold>Reasoning<//>`}<//>
152
+ <${Text} color=${C.gray} italic>${text}<//>
324
153
  <//>`;
325
154
  }
326
155
 
327
156
  /** A proposed remediation plan. */
328
157
  export function Plan({ reply = {}, live = false }) {
329
158
  const steps = reply.steps || [];
330
- const riskCount = steps.filter((s) => /destruct|high/i.test(s.risk || '')).length;
159
+ const dot3 = '';
331
160
  return html`
332
161
  <${Box} flexDirection="column" marginTop=${1}>
333
- <${Box} key="hdr">
334
- <${Text} color=${C.accent} bold>${glyphs.diamond} Plan<//>
335
- ${reply.summary ? html`<${Text} key="sum" dimColor> ${dash()} ${reply.summary}<//>` : null}
336
- <//>
337
- ${riskCount > 0
338
- ? html`<${Text} key="risk" color=${C.red}>${glyphs.warn} ${riskCount} destructive step${riskCount > 1 ? 's' : ''} — review carefully<//>` : null}
162
+ <${Text} color=${C.accent} bold>${glyphs.diamond} Plan<//>
163
+ ${reply.summary ? html`<${Text} color=${C.gray}>${reply.summary}<//>` : null}
339
164
  ${steps.map((s, i) => {
340
165
  const last = i === steps.length - 1;
341
166
  const risk = (s.risk === 'destructive' || s.risk === 'high')
342
- ? html`<${Text} key="r" color=${C.red}> ${glyphs.warn} destructive<//>`
167
+ ? html`<${Text} color=${C.red}> ${glyphs.warn} destructive<//>`
343
168
  : (s.risk === 'caution' || s.risk === 'medium')
344
- ? html`<${Text} key="r" color=${C.yellow}> ${glyphs.bolt} caution<//>` : null;
169
+ ? html`<${Text} color=${C.yellow}> ${glyphs.bolt} caution<//>` : null;
345
170
  return html`
346
171
  <${Box} flexDirection="column" key=${'p' + i}>
347
- <${Box} key="cmd">
348
- <${Text} dimColor>${last ? glyphs.branchEnd : glyphs.branchMid} <//>
349
- <${Text} dimColor>${i + 1}. <//>
350
- <${Text} color=${C.yellow} bold>${s.cmd || ''}<//>
172
+ <${Box}>
173
+ <${Text} color=${C.gray}>${last ? glyphs.branchEnd : glyphs.branchMid} <//>
174
+ <${Text} color=${C.gray}>${i + 1}. <//>
175
+ <${Text} color=${C.yellow}>${s.cmd || ''}<//>
351
176
  ${risk}
352
- ${s.blocked ? html`<${Text} key="blk" color=${C.red}> ${glyphs.block} blocked<//>` : null}
177
+ ${s.blocked ? html`<${Text} color=${C.red}> ${glyphs.block} blocked<//>` : null}
353
178
  <//>
354
- ${(s.why || s.purpose) ? html`<${Text} key="why" dimColor> ${s.why || s.purpose}<//>` : null}
179
+ ${(s.why || s.purpose) ? html`<${Text} color=${C.gray}> ${s.why || s.purpose}<//>` : null}
355
180
  <//>`;
356
181
  })}
357
- <${Box} key="foot" marginTop=${1}>
182
+ <${Box} marginTop=${1}>
358
183
  ${live
359
- ? html`<${Text} key="lv" dimColor italic>${glyphs.bullet} forming plan…<//>`
360
- : html`<${Text} key="cf" dimColor>Type <//>
361
- <${Text} key="cf2" color=${C.white}>apply fix<//>
362
- <${Text} key="cf3" dimColor> to confirm, or choose below.<//>` }
184
+ ? html`<${Text} color=${C.gray} italic>${glyphs.bullet} forming plan${dot3}<//>`
185
+ : html`<${Text} color=${C.gray}>Type ${html`<${Text} color=${C.white}>apply fix<//>`} to confirm.<//>`}
363
186
  <//>
364
187
  <//>`;
365
188
  }
@@ -378,34 +201,24 @@ export function Connect({ reply = {} }) {
378
201
  <//>`;
379
202
  }
380
203
 
381
- /** A local notice (help hint, errors, etc.). Handles multi-line text. */
204
+ /** A local notice (help hint, errors, etc.). */
382
205
  export function Notice({ text, color = C.gray }) {
383
- const lines = String(text).split('\n');
384
- return html`
385
- <${Box} marginTop=${1} flexDirection="column">
386
- ${lines.map((ln, i) => html`<${Text} key=${'n' + i} color=${color}>${ln}<//>` )}
387
- <//>`;
206
+ return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
388
207
  }
389
208
 
390
- /**
391
- * The active "working" line — animated spinner, elapsed time, cancel hint.
392
- * label: 'Thinking' when waiting for AI, 'Working' when tools are active.
393
- */
394
- export function Thinking({ label = 'Thinking', startedAt }) {
209
+ /** The active "working" line — animated spinner, elapsed time, cancel hint. */
210
+ export function Thinking({ label = 'Working', startedAt }) {
395
211
  const [, tick] = useState(0);
396
212
  useEffect(() => {
397
213
  const id = setInterval(() => tick((n) => n + 1), 1000);
398
214
  return () => clearInterval(id);
399
215
  }, []);
400
216
  const secs = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
401
- const timeStr = secs >= 60
402
- ? `${Math.floor(secs / 60)}m ${secs % 60}s`
403
- : secs > 0 ? `${secs}s` : '';
404
217
  return html`
405
218
  <${Box} marginTop=${1}>
406
219
  <${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
407
- <${Text} color=${C.accent} bold> ${label}…<//>
408
- <${Text} dimColor>${timeStr ? ` (${timeStr})` : ''} · esc to cancel<//>
220
+ <${Text} color=${C.accent} bold> ${label}<//>
221
+ <${Text} color=${C.gray}>… (${secs}s ${midDot()}esc to interrupt)<//>
409
222
  <//>`;
410
223
  }
411
224
 
@@ -418,7 +231,7 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
418
231
  return html`
419
232
  <${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1}>
420
233
  ${title ? html`<${Text} color=${C.accent} bold>${glyphs.diamond} ${title}<//>` : null}
421
- ${note ? html`<${Text} dimColor>${note}<//>` : null}
234
+ ${note ? html`<${Text} color=${C.gray}>${note}<//>` : null}
422
235
  <${Box} flexDirection="column" marginTop=${title || note ? 1 : 0}>
423
236
  ${options.map((o, i) => {
424
237
  const on = !focusText && i === sel;
@@ -428,330 +241,19 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
428
241
  <${Box} key=${'o' + i}>
429
242
  <${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
430
243
  <${Text} color=${tone} bold=${on}>${label}<//>
431
- ${o.hint ? html`<${Text} dimColor> ${dash()} ${o.hint}<//>` : null}
244
+ ${o.hint ? html`<${Text} color=${C.gray}> ${dash()} ${o.hint}<//>` : null}
432
245
  <//>`;
433
246
  })}
434
247
  ${allowText
435
248
  ? html`<${Box} key="freetext">
436
249
  <${Text} color=${focusText ? C.accent : C.gray}>${focusText ? glyphs.prompt + ' ' : ' '}<//>
437
- <${Text} dimColor>${text ? '' : 'Type a custom answer…'}<//>
250
+ <${Text} color=${C.gray}>${text ? '' : 'Type a custom answer…'}<//>
438
251
  <${Text} color=${C.white}>${text}<//>${focusText ? html`<${Text} inverse> <//>` : null}
439
252
  <//>`
440
253
  : null}
441
254
  <//>
442
255
  <${Box} marginTop=${1}>
443
- <${Text} dimColor>${glyphs.up}${glyphs.down} navigate${allowText ? ' · Tab type' : ''} · Enter confirm · Esc dismiss<//>
444
- <//>
445
- <//>`;
446
- }
447
-
448
- /**
449
- * Shell command prompt line — shown in history when the user runs a ! command.
450
- * Renders as a yellow $ prompt with the command text, matching GitHub Copilot
451
- * CLI's visual treatment of direct shell commands.
452
- */
453
- export function ShellInput({ cmd }) {
454
- return html`
455
- <${Box} marginTop=${1} marginBottom=${0}>
456
- <${Text} color=${C.orange} bold>$ <//>
457
- <${Box} flexShrink=${1}>
458
- <${Text} color=${C.white} bold>${cmd}<//>
459
- <//>
460
- <//>`;
461
- }
462
-
463
- /**
464
- * Shell command output — shown below a ShellInput. Uses a left border to
465
- * visually group the output with the command. Long outputs are capped at
466
- * 80 lines to keep the terminal readable.
467
- */
468
- export function ShellOut({ ok, output = '' }) {
469
- const lines = output.split('\n');
470
- const capped = lines.slice(0, 80);
471
- const more = lines.length - capped.length;
472
- return html`
473
- <${Box} flexDirection="column" marginTop=${0}
474
- borderStyle="single" borderColor=${ok ? C.gray : C.red}
475
- borderTop=${false} borderRight=${false} borderBottom=${false}
476
- paddingLeft=${1}>
477
- ${capped.map((l, i) => html`<${Text} key=${'so' + i} color=${ok ? C.white : C.red}>${l || ' '}<//>` )}
478
- ${more > 0
479
- ? html`<${Text} dimColor>${glyphs.bullet} ${more} more line${more > 1 ? 's' : ''} hidden<//>` : null}
480
- <//>`;
481
- }
482
-
483
- // ── Tab bar ──────────────────────────────────────────────────────────────────
484
- // GitHub Copilot CLI-style tab bar pinned above the content area.
485
- // Tabs are keyboard-accessible via Ctrl+1/2/3/4 (wired in app.js).
486
-
487
- /**
488
- * Top-of-screen tab bar: Session | Monitoring | Issues | Gists
489
- * Active tab rendered with accent underline + bold; inactive tabs are dimmed.
490
- * The separator rule beneath uses the brand accent colour.
491
- */
492
- export function TabBar({ tabs = [], active = 0, onSelect, width = 80 }) {
493
- const A = asciiSafe();
494
- const sep = A ? '-' : '─';
495
- return html`
496
- <${Box} flexDirection="column" width=${width}>
497
- <${Box} flexDirection="row" paddingX=${1} paddingTop=${0}>
498
- ${tabs.map((label, i) => {
499
- const on = i === active;
500
- return html`
501
- <${Box} key=${'tab' + i} marginRight=${2}>
502
- <${Text}
503
- color=${on ? C.accent : C.gray}
504
- bold=${on}
505
- dimColor=${!on}
506
- underline=${on}
507
- >${on ? glyphs.prompt + ' ' : ' '}${label}<//><//>`;
508
- })}
509
- <${Box} flexGrow=${1}>
510
- <${Text} dimColor> click or Ctrl+${tabs.map((_, i) => i + 1).join('/')}<//><//>
511
- <//>
512
- <${Text} color=${C.accent} dimColor>${sep.repeat(Math.max(8, width - 2))}<//>
513
- <//>`;
514
- }
515
-
516
- // ── Monitoring tab ────────────────────────────────────────────────────────────
517
-
518
- /**
519
- * Auto-fetch /api/v2/monitoring/devices and /api/v2/monitoring/status-checks.
520
- * Refreshes every 30 seconds.
521
- */
522
- export function MonitoringView({ cfg }) {
523
- const [data, setData] = useState(null); // { devices, checks }
524
- const [loading, setLoading] = useState(true);
525
- const [err, setErr] = useState(null);
526
- const [lastAt, setLastAt] = useState(null);
527
- const timerRef = useRef(null);
528
-
529
- const fetch_ = useCallback(async () => {
530
- if (!cfg?.url || !cfg?.token) {
531
- setErr('No server configured. Run icli login first.');
532
- setLoading(false);
533
- return;
534
- }
535
- try {
536
- const headers = { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' };
537
- const [devRes, chkRes] = await Promise.all([
538
- fetch(`${cfg.url}/api/v2/monitoring/devices`, { headers }).catch(() => null),
539
- fetch(`${cfg.url}/api/v2/monitoring/status-checks`, { headers }).catch(() => null),
540
- ]);
541
- const devices = devRes?.ok ? ((await devRes.json()).data || []) : [];
542
- const checks = chkRes?.ok ? ((await chkRes.json()).data || []) : [];
543
- setData({ devices, checks });
544
- setErr(null);
545
- } catch (e) {
546
- setErr(e.message || 'Fetch failed');
547
- } finally {
548
- setLoading(false);
549
- setLastAt(new Date().toLocaleTimeString());
550
- }
551
- }, [cfg]);
552
-
553
- useEffect(() => {
554
- fetch_();
555
- timerRef.current = setInterval(fetch_, 30000);
556
- return () => clearInterval(timerRef.current);
557
- }, [fetch_]);
558
-
559
- if (loading) return html`
560
- <${Box} marginTop=${2} paddingX=${2}>
561
- <${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
562
- <${Text} color=${C.gray}> Fetching device status…<//>
563
- <//>`;
564
-
565
- if (err) return html`
566
- <${Box} marginTop=${2} paddingX=${2} flexDirection="column">
567
- <${Text} color=${C.red}>${glyphs.cross} Monitoring error: ${err}<//>
568
- <${Text} dimColor>Make sure the server is reachable and you are logged in.<//>
569
- <//>`;
570
-
571
- const { devices = [], checks = [] } = data || {};
572
- const devUp = devices.filter((d) => String(d.status).toLowerCase() === 'up' || d.status === 1).length;
573
- const devDown = devices.length - devUp;
574
- const chkOk = checks.filter((c) => c.latest_result?.status === 'up').length;
575
- const chkFail = checks.filter((c) => c.latest_result?.status !== 'up' && c.latest_result).length;
576
-
577
- return html`
578
- <${Box} flexDirection="column" paddingX=${2} marginTop=${1}>
579
- <${Box} marginBottom=${1}>
580
- <${Text} color=${C.accent} bold>Network Monitoring<//>
581
- ${lastAt ? html`<${Text} dimColor> · refreshes every 30s · last: ${lastAt}<//>` : null}
582
- <//>
583
-
584
- ${/* ── Device summary ── */ null}
585
- <${Box} marginBottom=${1} flexDirection="row" gap=${3}>
586
- <${Box} flexDirection="column">
587
- <${Text} dimColor>Managed Devices<//>
588
- <${Box}>
589
- <${Text} color=${C.green} bold>${devUp}<//>
590
- <${Text} color=${C.gray}> up <//>
591
- <${Text} color=${devDown > 0 ? C.red : C.gray} bold=${devDown > 0}>${devDown}<//>
592
- <${Text} color=${C.gray}> down<//>
593
- <//>
594
- <//>
595
- <${Box} flexDirection="column">
596
- <${Text} dimColor>Status Checks<//>
597
- <${Box}>
598
- <${Text} color=${C.green} bold>${chkOk}<//>
599
- <${Text} color=${C.gray}> ok <//>
600
- <${Text} color=${chkFail > 0 ? C.red : C.gray} bold=${chkFail > 0}>${chkFail}<//>
601
- <${Text} color=${C.gray}> fail<//>
602
- <//>
603
- <//>
604
- <//>
605
-
606
- ${/* ── Down devices ── */ null}
607
- ${devDown > 0
608
- ? html`<${Box} flexDirection="column" marginBottom=${1}>
609
- <${Text} color=${C.red} bold>${glyphs.warn} Down devices<//>
610
- ${devices.filter((d) => String(d.status).toLowerCase() !== 'up' && d.status !== 1).slice(0, 10).map((d, i) =>
611
- html`<${Box} key=${'dd' + i}>
612
- <${Text} color=${C.red}> ${glyphs.cross} <//>
613
- <${Text} color=${C.white}>${d.name || d.ip_address}<//>
614
- <${Text} dimColor> ${d.device_type || ''} ${d.ip_address}<//>
615
- <//>` )}
616
- <//>` : null}
617
-
618
- ${/* ── Failed checks ── */ null}
619
- ${chkFail > 0
620
- ? html`<${Box} flexDirection="column">
621
- <${Text} color=${C.yellow} bold>${glyphs.warn} Failed checks<//>
622
- ${checks.filter((c) => c.latest_result?.status !== 'up' && c.latest_result).slice(0, 10).map((c, i) =>
623
- html`<${Box} key=${'cf' + i}>
624
- <${Text} color=${C.yellow}> ${glyphs.warn} <//>
625
- <${Text} color=${C.white}>${c.name}<//>
626
- <${Text} dimColor> ${c.target || ''} ${c.latest_result?.status || 'unknown'}<//>
627
- <//>` )}
628
- <//>` : null}
629
-
630
- ${devDown === 0 && chkFail === 0
631
- ? html`<${Box}><${Text} color=${C.green}>${glyphs.check} All devices and checks are up<//> <//>`
632
- : null}
633
-
634
- <${Box} marginTop=${1}>
635
- <${Text} dimColor>Switch to Session tab (Ctrl+1) · type /check in Session for details<//>
636
- <//>
637
- <//>`;
638
- }
639
-
640
- // ── Issues tab ────────────────────────────────────────────────────────────────
641
-
642
- /**
643
- * Shows AI-flagged issues collected during the current session.
644
- * Issues are populated by app.js whenever the AI notices an error/alarm.
645
- */
646
- export function IssuesView({ issues = [] }) {
647
- if (!issues.length) return html`
648
- <${Box} flexDirection="column" paddingX=${2} marginTop=${2}>
649
- <${Text} color=${C.accent} bold>Issues<//>
650
- <${Box} marginTop=${1}>
651
- <${Text} dimColor>${glyphs.bullet} No issues flagged in this session.<//>
652
- <//>
653
- <${Box} marginTop=${1}>
654
- <${Text} dimColor>Run /alarms or /check in the Session tab to detect problems.<//>
655
- <//>
656
- <//>`;
657
-
658
- return html`
659
- <${Box} flexDirection="column" paddingX=${2} marginTop=${1}>
660
- <${Text} color=${C.accent} bold>Issues <${Text} dimColor>(${issues.length} flagged this session)<//><//>
661
- <${Box} flexDirection="column" marginTop=${1}>
662
- ${issues.slice().reverse().slice(0, 20).map((iss, i) => html`
663
- <${Box} key=${'iss' + i} flexDirection="column" marginBottom=${1}>
664
- <${Box}>
665
- <${Text} color=${iss.severity === 'high' ? C.red : iss.severity === 'medium' ? C.yellow : C.gray}
666
- >${iss.severity === 'high' ? glyphs.cross : glyphs.warn} <//>
667
- <${Text} color=${C.white} bold>${iss.title}<//>
668
- <${Text} dimColor> ${iss.ts || ''}<//>
669
- <//>
670
- ${iss.body ? html`<${Text} dimColor> ${iss.body.slice(0, 120)}<//>` : null}
671
- <//>` )}
672
- <//>
673
- <//>`;
674
- }
675
-
676
- // ── Gists tab ─────────────────────────────────────────────────────────────────
677
-
678
- /**
679
- * Lists GitHub gists created with /gist during this session or stored locally.
680
- * If no GitHub token is set, shows instructions.
681
- */
682
- export function GistsView({ cfg }) {
683
- const [gists, setGists] = useState([]);
684
- const [loading, setLoading] = useState(true);
685
- const [err, setErr] = useState(null);
686
-
687
- useEffect(() => {
688
- const token = cfg?.github_token;
689
- if (!token) {
690
- setLoading(false);
691
- return;
692
- }
693
- fetch('https://api.github.com/gists?per_page=20', {
694
- headers: { Authorization: `token ${token}`, Accept: 'application/vnd.github.v3+json' },
695
- })
696
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
697
- .then((data) => {
698
- const icliGists = Array.isArray(data)
699
- ? data.filter((g) => (g.description || '').startsWith('[icli]') || (g.description || '').includes('iCopilot'))
700
- : [];
701
- setGists(icliGists.length ? icliGists : data.slice(0, 10));
702
- })
703
- .catch((e) => setErr(e.message))
704
- .finally(() => setLoading(false));
705
- }, [cfg?.github_token]);
706
-
707
- if (!cfg?.github_token) return html`
708
- <${Box} flexDirection="column" paddingX=${2} marginTop=${2}>
709
- <${Text} color=${C.accent} bold>Gists<//>
710
- <${Box} marginTop=${1} flexDirection="column">
711
- <${Text} dimColor>No GitHub token configured.<//>
712
- <${Text} dimColor>Run: ${''}<//>
713
- <${Text} color=${C.cyan}> /gist token YOUR_TOKEN<//>
714
- <${Text} dimColor>Then use ${''}<//>
715
- <${Text} color=${C.cyan}> /gist<//>
716
- <${Text} dimColor> in Session to save AI answers as secret gists.<//>
717
- <//>
718
- <//>`;
719
-
720
- if (loading) return html`
721
- <${Box} paddingX=${2} marginTop=${2}>
722
- <${Text} color=${C.accent}><${Spinner} type=${spinnerType()} /><//>
723
- <${Text} color=${C.gray}> Loading gists…<//>
724
- <//>`;
725
-
726
- if (err) return html`
727
- <${Box} paddingX=${2} marginTop=${2} flexDirection="column">
728
- <${Text} color=${C.red}>${glyphs.cross} GitHub error: ${err}<//>
729
- <//>`;
730
-
731
- if (!gists.length) return html`
732
- <${Box} paddingX=${2} marginTop=${2} flexDirection="column">
733
- <${Text} color=${C.accent} bold>Gists<//>
734
- <${Box} marginTop=${1}><${Text} dimColor>No gists yet. Use /gist in Session to save an answer.<//> <//><//>
735
- <//>`;
736
-
737
- return html`
738
- <${Box} flexDirection="column" paddingX=${2} marginTop=${1}>
739
- <${Text} color=${C.accent} bold>Gists <${Text} dimColor>(${gists.length} recent)<//><//>
740
- <${Box} flexDirection="column" marginTop=${1}>
741
- ${gists.slice(0, 15).map((g, i) => {
742
- const fname = Object.keys(g.files || {})[0] || 'unknown';
743
- const desc = (g.description || fname).slice(0, 60);
744
- const date = g.updated_at ? g.updated_at.slice(0, 10) : '';
745
- return html`
746
- <${Box} key=${'g' + i} marginBottom=${0}>
747
- <${Text} color=${C.cyan}>${glyphs.bullet} <//>
748
- <${Text} color=${C.white}>${desc}<//>
749
- <${Text} dimColor> ${date}<//>
750
- <//>
751
- <${Box} key=${'gu' + i}>
752
- <${Text} dimColor> ${g.html_url || ''}<//>
753
- <//>`;
754
- })}
256
+ <${Text} color=${C.gray}>${glyphs.up}${glyphs.down} choose ${midDot()}${allowText ? 'Tab type ' + midDot() : ''}Enter confirm ${midDot()}Esc dismiss<//>
755
257
  <//>
756
258
  <//>`;
757
259
  }