@dotdrelle/wiki-manager 0.6.31 → 0.6.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { parseComposePsJson } from './compose.js';
6
+ import { listWikircProfiles, loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
7
+ import { listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ async function checkAgents() {
12
+ const composeFile = join(managerRoot(), 'agents.docker-compose.yml');
13
+ if (!existsSync(composeFile)) return null;
14
+ try {
15
+ const { stdout } = await execFileAsync('docker', [
16
+ 'compose',
17
+ '--project-directory',
18
+ managerRoot(),
19
+ '-f',
20
+ composeFile,
21
+ '-p',
22
+ 'wiki-agents',
23
+ 'ps',
24
+ '--format',
25
+ 'json',
26
+ ], {
27
+ cwd: managerRoot(),
28
+ env: {
29
+ ...process.env,
30
+ WORKSPACES_ROOT: workspacesDir(),
31
+ WIKI_WORKSPACES_DIR: workspacesDir(),
32
+ },
33
+ timeout: 5000,
34
+ maxBuffer: 1024 * 1024,
35
+ });
36
+ const entries = parseComposePsJson(stdout);
37
+ if (entries.length === 0) return null;
38
+ const downServices = entries
39
+ .filter((entry) => {
40
+ const state = String(entry.State ?? entry.state ?? entry.Status ?? entry.status ?? '').toLowerCase();
41
+ return !(state.includes('running') || state.includes('up'));
42
+ })
43
+ .map((entry) => entry.Service ?? entry.service ?? entry.Name ?? entry.name)
44
+ .filter(Boolean);
45
+ return downServices.length > 0 ? { kind: 'agents', context: { downServices } } : null;
46
+ } catch (err) {
47
+ if (err?.code === 'ENOENT') {
48
+ return { kind: 'agents', context: { dockerMissing: true } };
49
+ }
50
+ const stderr = String(err?.stderr ?? err?.message ?? '');
51
+ if (
52
+ stderr.includes('Cannot connect to the Docker daemon') ||
53
+ stderr.includes('Is the docker daemon running') ||
54
+ stderr.includes('docker daemon') ||
55
+ stderr.includes('connection refused')
56
+ ) {
57
+ return { kind: 'agents', context: { dockerUnavailable: true } };
58
+ }
59
+ // Timeout or unknown error — don't block startup with ambiguous state.
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function checkWorkspace(workspaces) {
65
+ return workspaces.length === 0 ? { kind: 'workspace' } : null;
66
+ }
67
+
68
+ function checkWikirc(workspace) {
69
+ if (!workspace) return [];
70
+ const baseContext = {
71
+ workspaceName: workspace.name,
72
+ workspacePath: workspace.workspacePath,
73
+ profileName: 'default',
74
+ };
75
+ if (listWikircProfiles(workspace.workspacePath).length === 0) {
76
+ return [
77
+ {
78
+ kind: 'llm',
79
+ context: {
80
+ ...baseContext,
81
+ configError: 'No .wikirc.yaml profile found in the workspace.',
82
+ },
83
+ },
84
+ ];
85
+ }
86
+ try {
87
+ const loaded = loadWikircProfile(workspace.workspacePath, 'default');
88
+ const summary = summarizeWikircConfig(loaded.profile, loaded.config);
89
+ const context = {
90
+ workspaceName: workspace.name,
91
+ workspacePath: workspace.workspacePath,
92
+ profileName: loaded.profile.name,
93
+ };
94
+ const gaps = [];
95
+ if (!summary.hasApiKey || !summary.model) gaps.push({ kind: 'llm', context });
96
+ if (!summary.vectorEnabled) gaps.push({ kind: 'vector', context });
97
+ return gaps;
98
+ } catch {
99
+ return [
100
+ {
101
+ kind: 'llm',
102
+ context: {
103
+ ...baseContext,
104
+ configError: 'Default .wikirc.yaml profile could not be loaded.',
105
+ },
106
+ },
107
+ {
108
+ kind: 'vector',
109
+ context: {
110
+ ...baseContext,
111
+ configError: 'Default .wikirc.yaml profile could not be loaded.',
112
+ },
113
+ },
114
+ ];
115
+ }
116
+ }
117
+
118
+ export async function runChecks() {
119
+ const workspaces = listWorkspaces();
120
+ const agents = await checkAgents();
121
+ const gaps = [];
122
+ if (agents) gaps.push(agents);
123
+ const workspaceGap = checkWorkspace(workspaces);
124
+ if (workspaceGap) {
125
+ gaps.push(workspaceGap);
126
+ return gaps;
127
+ }
128
+ gaps.push(...checkWikirc(workspaces[0] ?? null));
129
+ return gaps;
130
+ }
@@ -0,0 +1,156 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { patchWikircProfile } from './wikirc.js';
6
+ import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ function wrapDockerError(err) {
11
+ if (err?.code === 'ENOENT') {
12
+ return new Error('wiki-workspace script not found. Reinstall wiki-manager.');
13
+ }
14
+ const message = String(err?.message ?? '');
15
+ const output = [err?.stderr, err?.stdout, message].filter(Boolean).map(String).join('\n');
16
+ if (output.includes('docker: command not found') || output.includes('docker: not found')) {
17
+ return new Error('Docker is not installed. Install Docker Desktop (https://docs.docker.com/get-docker/) and restart.');
18
+ }
19
+ if (output.includes('Cannot connect to the Docker daemon') || output.includes('Is the docker daemon running')) {
20
+ return new Error('Docker daemon is not running. Start Docker Desktop and try again.');
21
+ }
22
+ const commandMatch = message.match(/^Command failed:\s+(.+)$/m);
23
+ if (commandMatch) {
24
+ const [, commandLine] = commandMatch;
25
+ const rest = message.split(/\r?\n/).slice(1).join('\n').trim();
26
+ return new Error([
27
+ 'Command failed:',
28
+ commandLine,
29
+ rest,
30
+ ].filter(Boolean).join('\n'));
31
+ }
32
+ return err;
33
+ }
34
+
35
+ export async function startAgents(options = {}) {
36
+ try {
37
+ const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'up'], {
38
+ cwd: managerRoot(),
39
+ env: {
40
+ ...process.env,
41
+ WIKI_WORKSPACES_DIR: workspacesDir(),
42
+ },
43
+ timeout: options.timeout ?? 180_000,
44
+ maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
45
+ });
46
+ return [stdout, stderr].filter(Boolean).join('\n').trim();
47
+ } catch (err) {
48
+ throw wrapDockerError(err);
49
+ }
50
+ }
51
+
52
+ export async function stopAgents(options = {}) {
53
+ try {
54
+ const { stdout, stderr } = await execFileAsync(join(managerRoot(), 'wiki-workspace'), ['agents', 'down'], {
55
+ cwd: managerRoot(),
56
+ env: {
57
+ ...process.env,
58
+ WIKI_WORKSPACES_DIR: workspacesDir(),
59
+ },
60
+ timeout: options.timeout ?? 120_000,
61
+ maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
62
+ });
63
+ return [stdout, stderr].filter(Boolean).join('\n').trim();
64
+ } catch (err) {
65
+ throw wrapDockerError(err);
66
+ }
67
+ }
68
+
69
+ export async function createNewWorkspace(name, targetPath) {
70
+ try {
71
+ const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
72
+ const workspace = findWorkspace(name);
73
+ if (workspace) initializeWorkspaceWikirc(workspace);
74
+ return { output, workspace };
75
+ } catch (err) {
76
+ throw wrapDockerError(err);
77
+ }
78
+ }
79
+
80
+ export function initializeWorkspaceWikirc(workspace) {
81
+ const accessKey = workspace?.env?.WIKI_MCP_AUTH_TOKEN;
82
+ if (!workspace?.workspacePath || !accessKey) return null;
83
+ return patchWikircProfile(workspace.workspacePath, 'default', {
84
+ mcp: {
85
+ accessKey,
86
+ },
87
+ });
88
+ }
89
+
90
+ export async function unregisterWorkspace(nameOrWorkspace) {
91
+ const workspace = typeof nameOrWorkspace === 'string' ? findWorkspace(nameOrWorkspace) : nameOrWorkspace;
92
+ if (!workspace) throw new Error(`Workspace not found: ${nameOrWorkspace}`);
93
+ await rm(workspace.registryPath, { recursive: true, force: true });
94
+ return workspace;
95
+ }
96
+
97
+ export async function renameWorkspace(name, nextName) {
98
+ if (!isValidWorkspaceName(nextName)) throw new Error('Invalid workspace name.');
99
+ const all = listWorkspaces();
100
+ const workspace = all.find((w) => w.name === name);
101
+ if (!workspace) throw new Error(`Workspace not found: ${name}`);
102
+ if (all.find((w) => w.name === nextName)) throw new Error(`Workspace already exists: ${nextName}`);
103
+ const nextRegistryPath = join(workspacesDir(), nextName);
104
+ const envText = await readFile(workspace.envFile, 'utf8');
105
+ const nextEnvText = envText.match(/^WORKSPACE_NAME=/m)
106
+ ? envText.replace(/^WORKSPACE_NAME=.*$/m, `WORKSPACE_NAME=${nextName}`)
107
+ : `${envText.trimEnd()}\nWORKSPACE_NAME=${nextName}\n`;
108
+ const tmpEnvFile = join(workspace.registryPath, `.env.${process.pid}.tmp`);
109
+ await writeFile(tmpEnvFile, nextEnvText, 'utf8');
110
+ await rename(tmpEnvFile, workspace.envFile);
111
+ await rename(workspace.registryPath, nextRegistryPath);
112
+ return { previousName: name, name: nextName, registryPath: nextRegistryPath };
113
+ }
114
+
115
+ export async function deleteWorkspaceAndFiles(name, workspacePath) {
116
+ const workspace = await unregisterWorkspace(name);
117
+ const target = workspacePath || workspace.workspacePath;
118
+ await rm(target, { recursive: true, force: true });
119
+ return { workspace, deletedPath: target };
120
+ }
121
+
122
+ export function writeLanguageConfig(workspacePath, profileName, language) {
123
+ return patchWikircProfile(workspacePath, profileName || 'default', { language });
124
+ }
125
+
126
+ export function writeLlmConfig(workspacePath, profileName, config) {
127
+ const patches = {
128
+ llm: {
129
+ provider: config.provider,
130
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
131
+ ...(config.apiKey ? { apiKey: config.apiKey } : {}),
132
+ model: config.model,
133
+ },
134
+ };
135
+ return patchWikircProfile(workspacePath, profileName || 'default', patches);
136
+ }
137
+
138
+ export function writeVectorConfig(workspacePath, profileName, config) {
139
+ const patches = {
140
+ retrieval: {
141
+ vector: {
142
+ enabled: true,
143
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
144
+ ...(config.apiKey ? { apiKey: config.apiKey } : {}),
145
+ timeoutMs: config.timeoutMs ?? 600_000,
146
+ embeddingModel: config.embeddingModel,
147
+ rerankEnabled: Boolean(config.rerankEnabled),
148
+ ...(config.rerankerModel ? { rerankerModel: config.rerankerModel } : {}),
149
+ topK: config.topK ?? 120,
150
+ rerankTopK: config.rerankTopK ?? 80,
151
+ maxResults: config.maxResults ?? 6,
152
+ },
153
+ },
154
+ };
155
+ return patchWikircProfile(workspacePath, profileName || 'default', patches);
156
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
3
  import YAML from 'yaml';
4
4
 
@@ -50,6 +50,85 @@ export function loadWikircProfile(workspacePath, profileName = 'default') {
50
50
  return { profile, config };
51
51
  }
52
52
 
53
+ function isPlainObject(value) {
54
+ return value && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+
57
+ function setYamlValue(map, key, value) {
58
+ if (isPlainObject(value)) {
59
+ let child = map.get(key, true);
60
+ if (!YAML.isMap(child)) {
61
+ child = new YAML.YAMLMap();
62
+ map.set(key, child);
63
+ }
64
+ mergeYamlMap(child, value);
65
+ return;
66
+ }
67
+ map.set(key, value);
68
+ }
69
+
70
+ function mergeYamlMap(map, patches) {
71
+ for (const [key, value] of Object.entries(patches ?? {})) {
72
+ setYamlValue(map, key, value);
73
+ }
74
+ }
75
+
76
+ function stripCommentedVectorKeys(raw, keys) {
77
+ const keySet = new Set(keys.filter(Boolean));
78
+ if (keySet.size === 0) return raw;
79
+
80
+ let inRetrieval = false;
81
+ let retrievalIndent = -1;
82
+ let inVector = false;
83
+ let vectorIndent = -1;
84
+
85
+ return raw.split(/\r?\n/).filter((line) => {
86
+ const nonComment = line.match(/^(\s*)([A-Za-z0-9_-]+):(?:\s|$)/);
87
+ if (nonComment) {
88
+ const indent = nonComment[1].length;
89
+ const key = nonComment[2];
90
+ if (inVector && indent <= vectorIndent) inVector = false;
91
+ if (inRetrieval && indent <= retrievalIndent) inRetrieval = false;
92
+ if (!inRetrieval && key === 'retrieval') {
93
+ inRetrieval = true;
94
+ retrievalIndent = indent;
95
+ } else if (inRetrieval && !inVector && indent > retrievalIndent && key === 'vector') {
96
+ inVector = true;
97
+ vectorIndent = indent;
98
+ }
99
+ }
100
+
101
+ if (inVector) {
102
+ const commentedKey = line.match(/^\s*#\s*([A-Za-z0-9_-]+):/);
103
+ if (commentedKey && keySet.has(commentedKey[1])) return false;
104
+ }
105
+ return true;
106
+ }).join('\n');
107
+ }
108
+
109
+ export function patchWikircProfile(workspacePath, profileName = 'default', patches = {}) {
110
+ const profile = resolveWikircProfile(workspacePath, profileName);
111
+ const vectorPatch = patches?.retrieval?.vector;
112
+ const commentedVectorKeysToStrip = [
113
+ vectorPatch?.baseUrl ? 'baseUrl' : null,
114
+ vectorPatch?.apiKey ? 'apiKey' : null,
115
+ ];
116
+ const raw = stripCommentedVectorKeys(readFileSync(profile.path, 'utf8'), commentedVectorKeysToStrip);
117
+ const doc = YAML.parseDocument(raw, {
118
+ schema: 'core',
119
+ keepSourceTokens: true,
120
+ });
121
+ if (doc.errors.length > 0) {
122
+ throw new Error(`wikirc YAML invalide: ${doc.errors[0].message}`);
123
+ }
124
+ if (!YAML.isMap(doc.contents)) {
125
+ throw new Error('wikirc YAML invalide: objet attendu a la racine');
126
+ }
127
+ mergeYamlMap(doc.contents, patches);
128
+ writeFileSync(profile.path, doc.toString(), 'utf8');
129
+ return { profile, patches };
130
+ }
131
+
53
132
  export function summarizeWikircConfig(profile, config) {
54
133
  return {
55
134
  profile: profile.name,
@@ -0,0 +1,111 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import YAML from 'yaml';
7
+ import { patchWikircProfile } from './wikirc.js';
8
+ import { writeVectorConfig } from './wikiSetup.js';
9
+
10
+ test('patchWikircProfile merges keys and preserves existing values', () => {
11
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-patch-'));
12
+ const file = join(root, '.wikirc.yaml');
13
+ writeFileSync(file, [
14
+ '# workspace config',
15
+ 'language: en-US',
16
+ 'llm:',
17
+ ' provider: openai',
18
+ ' temperature: 0.2',
19
+ '',
20
+ ].join('\n'), 'utf8');
21
+
22
+ patchWikircProfile(root, 'default', {
23
+ llm: {
24
+ model: 'gpt-5.4-mini',
25
+ apiKey: 'secret',
26
+ },
27
+ retrieval: {
28
+ vector: {
29
+ enabled: true,
30
+ embeddingModel: 'text-embedding-3-small',
31
+ },
32
+ },
33
+ });
34
+
35
+ const raw = readFileSync(file, 'utf8');
36
+ const parsed = YAML.parse(raw);
37
+ assert.match(raw, /# workspace config/);
38
+ assert.equal(parsed.language, 'en-US');
39
+ assert.equal(parsed.llm.provider, 'openai');
40
+ assert.equal(parsed.llm.temperature, 0.2);
41
+ assert.equal(parsed.llm.model, 'gpt-5.4-mini');
42
+ assert.equal(parsed.retrieval.vector.enabled, true);
43
+ });
44
+
45
+ test('writeVectorConfig writes llm-wiki vector and rerank keys', () => {
46
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-vector-'));
47
+ writeFileSync(join(root, '.wikirc.yaml'), [
48
+ 'language: en',
49
+ 'llm:',
50
+ ' provider: openai-compatible',
51
+ ' baseUrl: http://localhost:8000/v1',
52
+ ' apiKey: llm-key',
53
+ ' model: chat-model',
54
+ 'retrieval:',
55
+ ' vector:',
56
+ ' enabled: false',
57
+ '',
58
+ ].join('\n'), 'utf8');
59
+
60
+ writeVectorConfig(root, 'default', {
61
+ baseUrl: 'http://localhost:7997/v1',
62
+ apiKey: 'vector-key',
63
+ embeddingModel: 'BAAI/bge-m3',
64
+ rerankEnabled: true,
65
+ rerankerModel: 'BAAI/bge-reranker-v2-m3',
66
+ });
67
+
68
+ const parsed = YAML.parse(readFileSync(join(root, '.wikirc.yaml'), 'utf8'));
69
+ assert.equal(parsed.retrieval.vector.enabled, true);
70
+ assert.equal(parsed.retrieval.vector.baseUrl, 'http://localhost:7997/v1');
71
+ assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
72
+ assert.equal(parsed.retrieval.vector.embeddingModel, 'BAAI/bge-m3');
73
+ assert.equal(parsed.retrieval.vector.rerankEnabled, true);
74
+ assert.equal(parsed.retrieval.vector.rerankerModel, 'BAAI/bge-reranker-v2-m3');
75
+ });
76
+
77
+ test('writeVectorConfig removes commented vector placeholders it replaces', () => {
78
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-vector-comments-'));
79
+ writeFileSync(join(root, '.wikirc.yaml'), [
80
+ 'language: en',
81
+ 'llm:',
82
+ ' provider: openai-compatible',
83
+ ' baseUrl: http://localhost:8000/v1',
84
+ ' apiKey: llm-key',
85
+ ' model: chat-model',
86
+ 'retrieval:',
87
+ ' vector:',
88
+ ' enabled: false',
89
+ ' # Defaults to llm.baseUrl.',
90
+ ' # baseUrl: http://127.0.0.1:7997/v1',
91
+ ' # apiKey: your-vector-key',
92
+ ' embeddingModel: BAAI/bge-m3',
93
+ ' rerankerModel: BAAI/bge-reranker-v2-m3',
94
+ '',
95
+ ].join('\n'), 'utf8');
96
+
97
+ writeVectorConfig(root, 'default', {
98
+ baseUrl: 'http://localhost:7997/v1',
99
+ apiKey: 'vector-key',
100
+ embeddingModel: 'BAAI/bge-m3',
101
+ rerankEnabled: true,
102
+ rerankerModel: 'custom-reranker',
103
+ });
104
+
105
+ const raw = readFileSync(join(root, '.wikirc.yaml'), 'utf8');
106
+ const parsed = YAML.parse(raw);
107
+ assert.doesNotMatch(raw, /^\s*#\s*baseUrl:/m);
108
+ assert.doesNotMatch(raw, /^\s*#\s*apiKey:/m);
109
+ assert.equal(parsed.retrieval.vector.baseUrl, 'http://localhost:7997/v1');
110
+ assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
111
+ });
@@ -49,7 +49,7 @@ export function findWorkspace(name) {
49
49
  return listWorkspaces().find((workspace) => workspace.name === name);
50
50
  }
51
51
 
52
- function isValidWorkspaceName(name) {
52
+ export function isValidWorkspaceName(name) {
53
53
  return (
54
54
  typeof name === 'string' &&
55
55
  /^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/.test(name) &&
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { For, createEffect, createMemo, createSignal } from 'solid-js';
2
+ import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
3
3
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
4
4
 
5
5
  const LEGACY_DONNA_ROLE = 'do' + 't';
@@ -59,12 +59,12 @@ const STATUS_COLUMN_GAP = 2;
59
59
  type HelpCard = { title: string; text: string; example: string };
60
60
 
61
61
  const HELP_CARDS: HelpCard[] = [
62
+ { title: '/use', text: 'Load one workspace.', example: 'Ex: /use <workspace>' },
62
63
  { title: '/new', text: 'Create/configure a workspace.', example: 'Ex: /new <name> [path]' },
63
- { title: 'modify wikirc', text: 'Action: edit file.', example: 'File: .wikirc.yaml' },
64
- { title: 'configure CME', text: 'Add type, URL, PAT, email.', example: 'Provide source credentials' },
65
- { title: 'add MCP', text: 'Inspect and use MCP endpoints.', example: 'Ex: /mcp endpoints' },
66
- { title: '/use', text: 'Load one workspace and its tools.', example: 'Ex: /use <workspace>' },
67
- { title: 'llm call action', text: 'Ask to LLM.', example: 'Ex: ask to LLM to run an action' },
64
+ { title: '/config', text: 'Modify LLM configuration.', example: 'Ex: /config edit <profile>' },
65
+ { title: '/mcp', text: 'View MCP.', example: 'Ex: /mcp status' },
66
+ { title: 'configure CME', text: 'Add type, URL, PAT, email.', example: 'Ask to donna to set credentials' },
67
+ { title: '/status', text: 'Check config.', example: 'Ex: /status' },
68
68
  ];
69
69
 
70
70
  function HelpCardPanel(props: { card: HelpCard; width: number }) {
@@ -364,6 +364,7 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
364
364
  if (isStatusOutput(message)) {
365
365
  return [
366
366
  { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
367
+ { segments: [{ text: ' ', color: '#D6DEE8' }] },
367
368
  ...raw.split('\n').map((line) => {
368
369
  const { left, right } = statusColumns(line || ' ');
369
370
  return { status: true, statusLeft: left, statusRight: right, segments: [] };
@@ -379,6 +380,7 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
379
380
  }
380
381
  return [
381
382
  { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
383
+ { segments: [{ text: ' ', color: '#D6DEE8' }] },
382
384
  ...renderMarkdownLines(lines, message.role, columns),
383
385
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
384
386
  ];
@@ -397,7 +399,7 @@ export function ConversationView(props: {
397
399
  const allLines = createMemo(() => conversationLines(props.messages, props.columns));
398
400
  const visibleLines = () => {
399
401
  const lines = allLines();
400
- const rows = Math.max(1, props.rows - 1);
402
+ const rows = Math.max(1, props.rows - 2);
401
403
  const maxScroll = Math.max(0, lines.length - rows);
402
404
  const scroll = Math.min(props.scroll, maxScroll);
403
405
  const end = lines.length - scroll;
@@ -406,7 +408,7 @@ export function ConversationView(props: {
406
408
  };
407
409
  const scrollHint = () => {
408
410
  const lines = allLines();
409
- const rows = Math.max(1, props.rows - 1);
411
+ const rows = Math.max(1, props.rows - 2);
410
412
  const maxScroll = Math.max(0, lines.length - rows);
411
413
  const scroll = Math.min(props.scroll, maxScroll);
412
414
  if (maxScroll === 0) return '';
@@ -483,6 +485,7 @@ export function ConversationView(props: {
483
485
  )
484
486
  )}
485
487
  </For>
488
+ <text height={1} />
486
489
  </box>
487
490
  );
488
491
  }
@@ -504,28 +507,34 @@ export function ChatInput(props: {
504
507
  const minRows = 1;
505
508
  const maxRows = 5;
506
509
  const [textareaRows, setTextareaRows] = createSignal(minRows);
510
+ let disposed = false;
507
511
  const idleColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
508
512
  const promptText = () => props.busy ? `${props.spinnerFrame} ` : props.prompt;
509
513
  const boxHeight = () => textareaRows() + 2;
514
+ const inputColumns = () => Math.max(8, props.width - promptText().length - 6);
510
515
  const textareaColumns = () => {
511
516
  const measured = Number(textareaRef?.width ?? 0);
512
- if (Number.isFinite(measured) && measured > 0) return Math.max(8, measured - 1);
513
- return Math.max(8, props.width - promptText().length - 7);
517
+ const bounded = Math.min(inputColumns(), Number.isFinite(measured) && measured > 0 ? measured : inputColumns());
518
+ return Math.max(8, bounded - 1);
514
519
  };
515
520
  const estimatedVisualRows = (value: string) => {
516
521
  const columns = textareaColumns();
517
522
  return Math.max(1, value.split('\n').reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / columns)), 0));
518
523
  };
519
524
  const measuredVisualRows = (value: string) => {
520
- const virtualRows = Number(textareaRef?.virtualLineCount ?? 0);
521
- const logicalRows = Number(textareaRef?.lineCount ?? 0);
522
- const scrollRows = Number(textareaRef?.scrollHeight ?? 0);
523
- return Math.max(
524
- estimatedVisualRows(value),
525
- Number.isFinite(virtualRows) ? virtualRows : 0,
526
- Number.isFinite(logicalRows) ? logicalRows : 0,
527
- Number.isFinite(scrollRows) ? scrollRows : 0,
528
- );
525
+ try {
526
+ const virtualRows = Number(textareaRef?.virtualLineCount ?? 0);
527
+ const logicalRows = Number(textareaRef?.lineCount ?? 0);
528
+ const scrollRows = Number(textareaRef?.scrollHeight ?? 0);
529
+ return Math.max(
530
+ estimatedVisualRows(value),
531
+ Number.isFinite(virtualRows) ? virtualRows : 0,
532
+ Number.isFinite(logicalRows) ? logicalRows : 0,
533
+ Number.isFinite(scrollRows) ? scrollRows : 0,
534
+ );
535
+ } catch {
536
+ return estimatedVisualRows(value);
537
+ }
529
538
  };
530
539
  const applyHeight = (rows: number) => {
531
540
  const height = rows + 2;
@@ -533,11 +542,25 @@ export function ChatInput(props: {
533
542
  if (containerRef) containerRef.height = height;
534
543
  props.onHeightChange(height);
535
544
  };
536
- const updateRows = (value = String(textareaRef?.plainText ?? props.value ?? '')) => {
537
- const rows = Math.min(maxRows, Math.max(minRows, measuredVisualRows(value)));
545
+ const safePlainText = () => {
546
+ try {
547
+ return String(textareaRef?.plainText ?? props.value ?? '');
548
+ } catch {
549
+ return String(props.value ?? '');
550
+ }
551
+ };
552
+ const updateRows = (value?: string) => {
553
+ if (disposed) return;
554
+ const text = value ?? safePlainText();
555
+ const rows = Math.min(maxRows, Math.max(minRows, measuredVisualRows(text)));
538
556
  setTextareaRows(rows);
539
557
  applyHeight(rows);
540
558
  };
559
+ const queueUpdateRows = (value?: string) => {
560
+ queueMicrotask(() => {
561
+ if (!disposed) updateRows(value);
562
+ });
563
+ };
541
564
  const syncTextareaValue = (value: string) => {
542
565
  const current = String(textareaRef?.plainText ?? '');
543
566
  if (!textareaRef || current === value) return;
@@ -549,16 +572,16 @@ export function ChatInput(props: {
549
572
  // Some renderable states reject cursor movement while layout is settling.
550
573
  }
551
574
  updateRows(value);
552
- queueMicrotask(() => updateRows(value));
575
+ queueUpdateRows(value);
553
576
  };
554
577
  const handleContentChange = () => {
555
- const value = String(textareaRef?.plainText ?? '');
578
+ const value = safePlainText();
556
579
  props.onInput(value);
557
580
  updateRows(value);
558
- queueMicrotask(() => updateRows(value));
581
+ queueUpdateRows(value);
559
582
  };
560
583
  const submitCurrentValue = () => {
561
- props.onSubmit(String(textareaRef?.plainText ?? props.value ?? ''));
584
+ props.onSubmit(safePlainText());
562
585
  };
563
586
 
564
587
  createEffect(() => {
@@ -573,7 +596,10 @@ export function ChatInput(props: {
573
596
  props.width;
574
597
  props.prompt;
575
598
  props.spinnerFrame;
576
- queueMicrotask(() => updateRows());
599
+ queueUpdateRows();
600
+ });
601
+ onCleanup(() => {
602
+ disposed = true;
577
603
  });
578
604
 
579
605
  return (
@@ -590,7 +616,7 @@ export function ChatInput(props: {
590
616
  <text height={1} fg={props.busy ? '#FBBF24' : idleColor()}>{promptText()}</text>
591
617
  <textarea
592
618
  ref={textareaRef}
593
- flexGrow={1}
619
+ width={inputColumns()}
594
620
  height={textareaRows()}
595
621
  focused={props.focused && !props.busy}
596
622
  initialValue={props.value}
@@ -599,7 +625,7 @@ export function ChatInput(props: {
599
625
  keyBindings={[
600
626
  { name: 'return', action: 'submit' },
601
627
  { name: 'kpenter', action: 'submit' },
602
- { name: 'linefeed', action: 'newline' },
628
+ { name: 'linefeed', action: 'submit' },
603
629
  { name: 'return', shift: true, action: 'newline' },
604
630
  { name: 'kpenter', shift: true, action: 'newline' },
605
631
  { name: 'linefeed', shift: true, action: 'newline' },