@indianic/dialout 1.1.1 → 1.2.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.
- package/README.md +429 -16
- package/bin/AGENTS.md +44 -0
- package/bin/devdash-agent.js +2 -0
- package/dist/ai-adapters/claude.d.ts +3 -0
- package/dist/ai-adapters/claude.d.ts.map +1 -0
- package/dist/ai-adapters/claude.js +98 -0
- package/dist/ai-adapters/claude.js.map +1 -0
- package/dist/ai-adapters/codex.d.ts +3 -0
- package/dist/ai-adapters/codex.d.ts.map +1 -0
- package/dist/ai-adapters/codex.js +147 -0
- package/dist/ai-adapters/codex.js.map +1 -0
- package/dist/ai-adapters/grok.d.ts +3 -0
- package/dist/ai-adapters/grok.d.ts.map +1 -0
- package/dist/ai-adapters/grok.js +164 -0
- package/dist/ai-adapters/grok.js.map +1 -0
- package/dist/ai-adapters/index.d.ts +5 -0
- package/dist/ai-adapters/index.d.ts.map +1 -0
- package/dist/ai-adapters/index.js +30 -0
- package/dist/ai-adapters/index.js.map +1 -0
- package/dist/ai-adapters/types.d.ts +3 -0
- package/dist/ai-adapters/types.d.ts.map +1 -0
- package/dist/ai-adapters/types.js +7 -0
- package/dist/ai-adapters/types.js.map +1 -0
- package/dist/ai-capabilities/claude.d.ts +4 -0
- package/dist/ai-capabilities/claude.d.ts.map +1 -0
- package/dist/ai-capabilities/claude.js +174 -0
- package/dist/ai-capabilities/claude.js.map +1 -0
- package/dist/ai-capabilities/describe.d.ts +2 -0
- package/dist/ai-capabilities/describe.d.ts.map +1 -0
- package/dist/ai-capabilities/describe.js +31 -0
- package/dist/ai-capabilities/describe.js.map +1 -0
- package/dist/ai-capabilities/fsdeps.d.ts +3 -0
- package/dist/ai-capabilities/fsdeps.d.ts.map +1 -0
- package/dist/ai-capabilities/fsdeps.js +71 -0
- package/dist/ai-capabilities/fsdeps.js.map +1 -0
- package/dist/ai-capabilities/grok.d.ts +5 -0
- package/dist/ai-capabilities/grok.d.ts.map +1 -0
- package/dist/ai-capabilities/grok.js +145 -0
- package/dist/ai-capabilities/grok.js.map +1 -0
- package/dist/ai-capabilities/index.d.ts +4 -0
- package/dist/ai-capabilities/index.d.ts.map +1 -0
- package/dist/ai-capabilities/index.js +94 -0
- package/dist/ai-capabilities/index.js.map +1 -0
- package/dist/ai-capabilities/redact.d.ts +2 -0
- package/dist/ai-capabilities/redact.d.ts.map +1 -0
- package/dist/ai-capabilities/redact.js +27 -0
- package/dist/ai-capabilities/redact.js.map +1 -0
- package/dist/ai-capabilities/types.d.ts +11 -0
- package/dist/ai-capabilities/types.d.ts.map +1 -0
- package/dist/ai-capabilities/types.js +3 -0
- package/dist/ai-capabilities/types.js.map +1 -0
- package/dist/ai-launch.d.ts +35 -0
- package/dist/ai-launch.d.ts.map +1 -0
- package/dist/ai-launch.js +211 -0
- package/dist/ai-launch.js.map +1 -0
- package/dist/ai-session-detector.d.ts +19 -0
- package/dist/ai-session-detector.d.ts.map +1 -0
- package/dist/ai-session-detector.js +79 -0
- package/dist/ai-session-detector.js.map +1 -0
- package/dist/ai-sessions.d.ts +36 -0
- package/dist/ai-sessions.d.ts.map +1 -0
- package/dist/ai-sessions.js +309 -0
- package/dist/ai-sessions.js.map +1 -0
- package/dist/ai-status.d.ts +6 -0
- package/dist/ai-status.d.ts.map +1 -0
- package/dist/ai-status.js +40 -0
- package/dist/ai-status.js.map +1 -0
- package/dist/ai-transcript-locator.d.ts +35 -0
- package/dist/ai-transcript-locator.d.ts.map +1 -0
- package/dist/ai-transcript-locator.js +268 -0
- package/dist/ai-transcript-locator.js.map +1 -0
- package/dist/ai-transcript-tail.d.ts +17 -0
- package/dist/ai-transcript-tail.d.ts.map +1 -0
- package/dist/ai-transcript-tail.js +120 -0
- package/dist/ai-transcript-tail.js.map +1 -0
- package/dist/checklist.d.ts +20 -0
- package/dist/checklist.d.ts.map +1 -0
- package/dist/checklist.js +51 -0
- package/dist/checklist.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1427 -0
- package/dist/cli.js.map +1 -0
- package/dist/command-runner.d.ts +15 -0
- package/dist/command-runner.d.ts.map +1 -0
- package/dist/command-runner.js +84 -0
- package/dist/command-runner.js.map +1 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +152 -0
- package/dist/config.js.map +1 -0
- package/dist/cowork.d.ts +26 -0
- package/dist/cowork.d.ts.map +1 -0
- package/dist/cowork.js +370 -0
- package/dist/cowork.js.map +1 -0
- package/dist/fs-browser.d.ts +6 -0
- package/dist/fs-browser.d.ts.map +1 -0
- package/dist/fs-browser.js +61 -0
- package/dist/fs-browser.js.map +1 -0
- package/dist/has-command.d.ts +12 -0
- package/dist/has-command.d.ts.map +1 -0
- package/dist/has-command.js +24 -0
- package/dist/has-command.js.map +1 -0
- package/dist/heartbeat.d.ts +7 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +50 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/port-scanner.d.ts +4 -0
- package/dist/port-scanner.d.ts.map +1 -0
- package/dist/port-scanner.js +75 -0
- package/dist/port-scanner.js.map +1 -0
- package/dist/project-scanner.d.ts +15 -0
- package/dist/project-scanner.d.ts.map +1 -0
- package/dist/project-scanner.js +333 -0
- package/dist/project-scanner.js.map +1 -0
- package/dist/pty-manager.d.ts +28 -0
- package/dist/pty-manager.d.ts.map +1 -0
- package/dist/pty-manager.js +506 -0
- package/dist/pty-manager.js.map +1 -0
- package/dist/service-installer.d.ts +95 -0
- package/dist/service-installer.d.ts.map +1 -0
- package/dist/service-installer.js +911 -0
- package/dist/service-installer.js.map +1 -0
- package/dist/single-instance.d.ts +20 -0
- package/dist/single-instance.d.ts.map +1 -0
- package/dist/single-instance.js +151 -0
- package/dist/single-instance.js.map +1 -0
- package/dist/ssh-local-network.d.ts +26 -0
- package/dist/ssh-local-network.d.ts.map +1 -0
- package/dist/ssh-local-network.js +169 -0
- package/dist/ssh-local-network.js.map +1 -0
- package/dist/terminal-detect.d.ts +52 -0
- package/dist/terminal-detect.d.ts.map +1 -0
- package/dist/terminal-detect.js +147 -0
- package/dist/terminal-detect.js.map +1 -0
- package/dist/terminal-markers.d.ts +52 -0
- package/dist/terminal-markers.d.ts.map +1 -0
- package/dist/terminal-markers.js +174 -0
- package/dist/terminal-markers.js.map +1 -0
- package/dist/tmux-manager.d.ts +35 -0
- package/dist/tmux-manager.d.ts.map +1 -0
- package/dist/tmux-manager.js +274 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/update-check.d.ts +3 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +202 -0
- package/dist/update-check.js.map +1 -0
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +472 -0
- package/dist/websocket.js.map +1 -0
- package/node_modules/@dialout/shared/README.md +16 -0
- package/node_modules/@dialout/shared/dist/ai-chat-prefs.d.ts +8 -0
- package/node_modules/@dialout/shared/dist/ai-chat-prefs.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/ai-chat-prefs.js +41 -0
- package/node_modules/@dialout/shared/dist/capabilities.d.ts +25 -0
- package/node_modules/@dialout/shared/dist/capabilities.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/capabilities.js +2 -0
- package/node_modules/@dialout/shared/dist/chat-blocks.d.ts +33 -0
- package/node_modules/@dialout/shared/dist/chat-blocks.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/chat-blocks.js +61 -0
- package/node_modules/@dialout/shared/dist/command-filter.d.ts +4 -0
- package/node_modules/@dialout/shared/dist/command-filter.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/command-filter.js +49 -0
- package/node_modules/@dialout/shared/dist/composer-behaviour.d.ts +6 -0
- package/node_modules/@dialout/shared/dist/composer-behaviour.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/composer-behaviour.js +24 -0
- package/node_modules/@dialout/shared/dist/events.d.ts +37 -0
- package/node_modules/@dialout/shared/dist/events.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/events.js +13 -0
- package/node_modules/@dialout/shared/dist/index.d.ts +21 -0
- package/node_modules/@dialout/shared/dist/index.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/index.js +37 -0
- package/node_modules/@dialout/shared/dist/kinds.d.ts +2 -0
- package/node_modules/@dialout/shared/dist/kinds.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/kinds.js +2 -0
- package/node_modules/@dialout/shared/dist/notify.d.ts +3 -0
- package/node_modules/@dialout/shared/dist/notify.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/notify.js +31 -0
- package/node_modules/@dialout/shared/dist/permission.d.ts +4 -0
- package/node_modules/@dialout/shared/dist/permission.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/permission.js +8 -0
- package/node_modules/@dialout/shared/dist/scroll-pin.d.ts +3 -0
- package/node_modules/@dialout/shared/dist/scroll-pin.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/scroll-pin.js +11 -0
- package/node_modules/@dialout/shared/dist/session.d.ts +19 -0
- package/node_modules/@dialout/shared/dist/session.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/session.js +6 -0
- package/node_modules/@dialout/shared/dist/terminal-name.d.ts +25 -0
- package/node_modules/@dialout/shared/dist/terminal-name.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/terminal-name.js +63 -0
- package/node_modules/@dialout/shared/dist/tool-appearance.d.ts +9 -0
- package/node_modules/@dialout/shared/dist/tool-appearance.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/tool-appearance.js +30 -0
- package/node_modules/@dialout/shared/package.json +37 -0
- package/node_modules/@dialout/shared/src/ai-chat-prefs.test.ts +40 -0
- package/node_modules/@dialout/shared/src/ai-chat-prefs.ts +39 -0
- package/node_modules/@dialout/shared/src/capabilities.ts +30 -0
- package/node_modules/@dialout/shared/src/chat-blocks.test.ts +65 -0
- package/node_modules/@dialout/shared/src/chat-blocks.ts +79 -0
- package/node_modules/@dialout/shared/src/command-filter.test.ts +76 -0
- package/node_modules/@dialout/shared/src/command-filter.ts +45 -0
- package/node_modules/@dialout/shared/src/composer-behaviour.test.ts +31 -0
- package/node_modules/@dialout/shared/src/composer-behaviour.ts +22 -0
- package/node_modules/@dialout/shared/src/events.ts +27 -0
- package/node_modules/@dialout/shared/src/index.ts +32 -0
- package/node_modules/@dialout/shared/src/kinds.ts +5 -0
- package/node_modules/@dialout/shared/src/notify.test.ts +49 -0
- package/node_modules/@dialout/shared/src/notify.ts +30 -0
- package/node_modules/@dialout/shared/src/permission.ts +10 -0
- package/node_modules/@dialout/shared/src/scroll-pin.test.ts +23 -0
- package/node_modules/@dialout/shared/src/scroll-pin.ts +13 -0
- package/node_modules/@dialout/shared/src/session.ts +25 -0
- package/node_modules/@dialout/shared/src/terminal-name.ts +82 -0
- package/node_modules/@dialout/shared/src/tool-appearance.test.ts +41 -0
- package/node_modules/@dialout/shared/src/tool-appearance.ts +39 -0
- package/package.json +54 -14
- package/scripts/postinstall.js +79 -0
- package/scripts/prepack.js +45 -0
- package/bin/dialout.js +0 -13
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Per-device AI chat preferences. Every read and write is guarded, because
|
|
2
|
+
// blocked storage must degrade to the default rather than break the composer.
|
|
3
|
+
//
|
|
4
|
+
// Storage is injected so this stays free of both `window` assumptions in the
|
|
5
|
+
// function signatures and of any React Native / Next import. The web default
|
|
6
|
+
// uses localStorage; a native client passes SecureStore.
|
|
7
|
+
|
|
8
|
+
export const FUNCTION_KEYS_PREF = 'devdash-ai-function-keys';
|
|
9
|
+
|
|
10
|
+
export interface PrefStore {
|
|
11
|
+
getItem(key: string): string | null;
|
|
12
|
+
setItem(key: string, value: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function browserStore(): PrefStore | null {
|
|
16
|
+
const w = (globalThis as { window?: { localStorage?: PrefStore } }).window;
|
|
17
|
+
if (!w?.localStorage) return null;
|
|
18
|
+
try {
|
|
19
|
+
return w.localStorage;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getFunctionKeysVisible(store: PrefStore | null = browserStore()): boolean {
|
|
26
|
+
if (!store) return false;
|
|
27
|
+
try {
|
|
28
|
+
return store.getItem(FUNCTION_KEYS_PREF) === '1';
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setFunctionKeysVisible(on: boolean, store: PrefStore | null = browserStore()): void {
|
|
35
|
+
if (!store) return;
|
|
36
|
+
try {
|
|
37
|
+
store.setItem(FUNCTION_KEYS_PREF, on ? '1' : '0');
|
|
38
|
+
} catch { /* blocked storage: keep the in-memory default */ }
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AiKind } from './kinds';
|
|
2
|
+
|
|
3
|
+
export type CommandSource = 'user' | 'project' | 'plugin' | 'builtin';
|
|
4
|
+
|
|
5
|
+
export interface AiCommand {
|
|
6
|
+
name: string; // without the leading slash
|
|
7
|
+
alias?: string; // Grok publishes these
|
|
8
|
+
description: string; // may be empty, never undefined
|
|
9
|
+
source: CommandSource;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface McpServerInfo {
|
|
13
|
+
name: string;
|
|
14
|
+
scope: 'global' | 'project';
|
|
15
|
+
transport: 'stdio' | 'http';
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
origin: string; // the file it came from
|
|
18
|
+
command?: string; // stdio only
|
|
19
|
+
args?: string[]; // redacted before it leaves the machine
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AiCapabilities {
|
|
23
|
+
kind: AiKind | string;
|
|
24
|
+
commands: AiCommand[];
|
|
25
|
+
mcpServers: McpServerInfo[];
|
|
26
|
+
scannedAt: string;
|
|
27
|
+
// Set by the server when the agent could not scan. That is not the same as
|
|
28
|
+
// "this session has no commands" — three facts, three sentences.
|
|
29
|
+
unavailable?: boolean;
|
|
30
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { groupEvents } from './chat-blocks';
|
|
3
|
+
import type { AiEvent } from './events';
|
|
4
|
+
|
|
5
|
+
const call = (id: string, name: string): AiEvent =>
|
|
6
|
+
({ kind: 'tool_call', id, name, summary: `${name} thing` });
|
|
7
|
+
const result = (forId: string, ok = true): AiEvent =>
|
|
8
|
+
({ kind: 'tool_result', forId, ok, preview: 'line1\nline2' });
|
|
9
|
+
const msg = (role: 'user' | 'assistant', text: string, id = text): AiEvent =>
|
|
10
|
+
({ kind: 'message', role, text, id });
|
|
11
|
+
|
|
12
|
+
describe('groupEvents', () => {
|
|
13
|
+
it('merges consecutive tool calls into one block', () => {
|
|
14
|
+
const blocks = groupEvents([call('a', 'Read'), call('b', 'Bash')]);
|
|
15
|
+
expect(blocks).toHaveLength(1);
|
|
16
|
+
expect(blocks[0].kind).toBe('tools');
|
|
17
|
+
if (blocks[0].kind === 'tools') expect(blocks[0].items).toHaveLength(2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('splits tool blocks when a message comes between them', () => {
|
|
21
|
+
const blocks = groupEvents([
|
|
22
|
+
call('a', 'Read'), msg('assistant', 'hello'), call('b', 'Bash'),
|
|
23
|
+
]);
|
|
24
|
+
expect(blocks.map((b) => b.kind)).toEqual(['tools', 'assistant', 'tools']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('attaches a result to its call by forId', () => {
|
|
28
|
+
const blocks = groupEvents([call('a', 'Read'), call('b', 'Bash'), result('a', false)]);
|
|
29
|
+
if (blocks[0].kind !== 'tools') throw new Error('expected tools');
|
|
30
|
+
expect(blocks[0].items[0].ok).toBe(false);
|
|
31
|
+
expect(blocks[0].items[0].resultLines).toBe(2);
|
|
32
|
+
expect(blocks[0].items[1].ok).toBe(null); // no result yet
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// A result whose call was never seen must not crash or invent a row.
|
|
36
|
+
it('ignores an orphan result', () => {
|
|
37
|
+
expect(groupEvents([result('nope')])).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps a lone tool call', () => {
|
|
41
|
+
const blocks = groupEvents([call('a', 'Read')]);
|
|
42
|
+
expect(blocks).toHaveLength(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('collapses repeated identical statuses to one divider', () => {
|
|
46
|
+
const blocks = groupEvents([
|
|
47
|
+
{ kind: 'state', status: 'working' },
|
|
48
|
+
{ kind: 'state', status: 'working' },
|
|
49
|
+
{ kind: 'state', status: 'waiting_approval' },
|
|
50
|
+
]);
|
|
51
|
+
expect(blocks.map((b) => b.kind)).toEqual(['status', 'status']);
|
|
52
|
+
if (blocks[1].kind === 'status') expect(blocks[1].status).toBe('waiting_approval');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('separates user and assistant messages into their own blocks', () => {
|
|
56
|
+
const blocks = groupEvents([msg('user', 'hi'), msg('assistant', 'hello')]);
|
|
57
|
+
expect(blocks.map((b) => b.kind)).toEqual(['user', 'assistant']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('gives every block a stable unique key', () => {
|
|
61
|
+
const blocks = groupEvents([msg('user', 'hi'), call('a', 'Read'), call('b', 'Bash')]);
|
|
62
|
+
const keys = blocks.map((b) => b.key);
|
|
63
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AiEvent } from './events';
|
|
2
|
+
|
|
3
|
+
export interface ToolItem {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
summary: string;
|
|
7
|
+
ok: boolean | null; // null = the call has not returned yet
|
|
8
|
+
resultPreview: string | null;
|
|
9
|
+
resultLines: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ChatBlock =
|
|
13
|
+
| { kind: 'user'; key: string; text: string; pending?: boolean }
|
|
14
|
+
| { kind: 'assistant'; key: string; text: string }
|
|
15
|
+
| { kind: 'tools'; key: string; items: ToolItem[] }
|
|
16
|
+
| { kind: 'thinking'; key: string; text: string }
|
|
17
|
+
| { kind: 'status'; key: string; status: string };
|
|
18
|
+
|
|
19
|
+
// Grouping only ever merges ADJACENT events, so the worst case for an adapter
|
|
20
|
+
// that interleaves is two blocks instead of one — never a reordering.
|
|
21
|
+
export function groupEvents(events: AiEvent[]): ChatBlock[] {
|
|
22
|
+
const blocks: ChatBlock[] = [];
|
|
23
|
+
let lastStatus: string | null = null;
|
|
24
|
+
|
|
25
|
+
events.forEach((e, i) => {
|
|
26
|
+
const key = `${('id' in e && e.id) || ('forId' in e && e.forId) || e.kind}-${i}`;
|
|
27
|
+
const tail = blocks[blocks.length - 1];
|
|
28
|
+
|
|
29
|
+
if (e.kind === 'message') {
|
|
30
|
+
blocks.push(e.role === 'user'
|
|
31
|
+
? { kind: 'user', key, text: e.text || '' }
|
|
32
|
+
: { kind: 'assistant', key, text: e.text || '' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (e.kind === 'tool_call') {
|
|
37
|
+
const item: ToolItem = {
|
|
38
|
+
id: e.id || key,
|
|
39
|
+
name: e.name || 'tool',
|
|
40
|
+
summary: e.summary || '',
|
|
41
|
+
ok: null, resultPreview: null, resultLines: null,
|
|
42
|
+
};
|
|
43
|
+
if (tail && tail.kind === 'tools') tail.items.push(item);
|
|
44
|
+
else blocks.push({ kind: 'tools', key, items: [item] });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (e.kind === 'tool_result') {
|
|
49
|
+
// Search backwards: the call is usually in the current block, but a
|
|
50
|
+
// slow tool can return after a message has split the trace.
|
|
51
|
+
for (let b = blocks.length - 1; b >= 0; b--) {
|
|
52
|
+
const blk = blocks[b];
|
|
53
|
+
if (blk.kind !== 'tools') continue;
|
|
54
|
+
const item = blk.items.find((t) => t.id === e.forId);
|
|
55
|
+
if (!item) continue;
|
|
56
|
+
item.ok = e.ok !== false;
|
|
57
|
+
item.resultPreview = e.preview || '';
|
|
58
|
+
item.resultLines = e.preview ? e.preview.split('\n').length : 0;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
return; // orphan result: drop it rather than invent a row
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (e.kind === 'thinking') {
|
|
65
|
+
blocks.push({ kind: 'thinking', key, text: e.text || '' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (e.kind === 'state') {
|
|
70
|
+
const status = e.status || '';
|
|
71
|
+
if (status && status !== lastStatus) {
|
|
72
|
+
lastStatus = status;
|
|
73
|
+
blocks.push({ kind: 'status', key, status });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return blocks;
|
|
79
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { commandQuery, rankCommands } from './command-filter';
|
|
3
|
+
import type { AiCommand } from './capabilities';
|
|
4
|
+
|
|
5
|
+
const cmd = (name: string, source: AiCommand['source'], description = ''): AiCommand =>
|
|
6
|
+
({ name, source, description });
|
|
7
|
+
|
|
8
|
+
describe('commandQuery', () => {
|
|
9
|
+
it('opens on a lone slash', () => {
|
|
10
|
+
expect(commandQuery('/')).toBe('');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns the text after the slash', () => {
|
|
14
|
+
expect(commandQuery('/comp')).toBe('comp');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// A slash mid-sentence is a path or a date, not a command.
|
|
18
|
+
it('does not open for a slash that is not first', () => {
|
|
19
|
+
expect(commandQuery('what about /tmp')).toBe(null);
|
|
20
|
+
expect(commandQuery(' /leading-space')).toBe(null);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('closes once the command is complete', () => {
|
|
24
|
+
expect(commandQuery('/compact now')).toBe(null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('is closed for ordinary text and for empty input', () => {
|
|
28
|
+
expect(commandQuery('')).toBe(null);
|
|
29
|
+
expect(commandQuery('hello')).toBe(null);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('rankCommands', () => {
|
|
34
|
+
const all = [
|
|
35
|
+
cmd('code-review:code-review', 'plugin', 'Review a PR'),
|
|
36
|
+
cmd('compact', 'builtin', 'Compact conversation history'),
|
|
37
|
+
cmd('commit-commands:clean_gone', 'plugin', 'Delete merged branches'),
|
|
38
|
+
cmd('seo', 'user', 'SEO Machine'),
|
|
39
|
+
cmd('deploy', 'project', 'Ship it'),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
it('puts a name prefix match above a name substring above a description match', () => {
|
|
43
|
+
const names = rankCommands(all, 'co').map((c) => c.name);
|
|
44
|
+
expect(names[0]).toBe('compact'); // prefix on the bare name
|
|
45
|
+
expect(names).toContain('code-review:code-review');
|
|
46
|
+
expect(names).not.toContain('seo');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// 52 of 81 commands are plugin-namespaced, so the prefix has to be searchable
|
|
50
|
+
// or half the list is unreachable by typing.
|
|
51
|
+
it('matches the plugin namespace as well as the command name', () => {
|
|
52
|
+
const names = rankCommands(all, 'commit').map((c) => c.name);
|
|
53
|
+
expect(names).toContain('commit-commands:clean_gone');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('matches a word in the description', () => {
|
|
57
|
+
const names = rankCommands(all, 'branches').map((c) => c.name);
|
|
58
|
+
expect(names).toEqual(['commit-commands:clean_gone']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Plugins outnumber the user's own commands 52:29, so a purely alphabetical
|
|
62
|
+
// sort buries the ones they wrote.
|
|
63
|
+
it('breaks ties by source, user first and plugin last', () => {
|
|
64
|
+
const tie = [cmd('zzz', 'plugin'), cmd('zzz2', 'user'), cmd('zzz3', 'project')];
|
|
65
|
+
expect(rankCommands(tie, 'zzz').map((c) => c.source)).toEqual(['user', 'project', 'plugin']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('an empty query returns everything, still ranked by source', () => {
|
|
69
|
+
expect(rankCommands(all, '')).toHaveLength(5);
|
|
70
|
+
expect(rankCommands(all, '')[0].source).toBe('user');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('is case insensitive', () => {
|
|
74
|
+
expect(rankCommands(all, 'SEO').map((c) => c.name)).toEqual(['seo']);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AiCommand, CommandSource } from './capabilities';
|
|
2
|
+
|
|
3
|
+
// The menu opens only when the slash starts the message. A slash anywhere else
|
|
4
|
+
// is a path, a date, or a fraction — not a command.
|
|
5
|
+
export function commandQuery(text: string): string | null {
|
|
6
|
+
if (!text.startsWith('/')) return null;
|
|
7
|
+
const rest = text.slice(1);
|
|
8
|
+
// A space means the command has been chosen and arguments are being typed.
|
|
9
|
+
if (/\s/.test(rest)) return null;
|
|
10
|
+
return rest;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// User commands are the ones they wrote; plugins outnumber them 52 to 29 on a
|
|
14
|
+
// real install, so source is the tie-breaker or the list buries them.
|
|
15
|
+
const SOURCE_RANK: Record<CommandSource, number> = {
|
|
16
|
+
user: 0, project: 1, builtin: 2, plugin: 3,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Lower is better.
|
|
20
|
+
function score(c: AiCommand, q: string): number {
|
|
21
|
+
const name = c.name.toLowerCase();
|
|
22
|
+
const bare = name.includes(':') ? name.slice(name.indexOf(':') + 1) : name;
|
|
23
|
+
const desc = c.description.toLowerCase();
|
|
24
|
+
|
|
25
|
+
if (!q) return 40;
|
|
26
|
+
if (bare.startsWith(q)) return 0;
|
|
27
|
+
if (name.startsWith(q)) return 10; // the plugin prefix
|
|
28
|
+
if (bare.includes(q)) return 20;
|
|
29
|
+
if (name.includes(q)) return 25;
|
|
30
|
+
if (desc.includes(q)) return 30;
|
|
31
|
+
return Infinity; // no match at all
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function rankCommands(commands: AiCommand[], query: string): AiCommand[] {
|
|
35
|
+
const q = query.trim().toLowerCase();
|
|
36
|
+
|
|
37
|
+
return commands
|
|
38
|
+
.map((c) => ({ c, s: score(c, q) }))
|
|
39
|
+
.filter((x) => x.s !== Infinity)
|
|
40
|
+
.sort((a, b) =>
|
|
41
|
+
a.s - b.s
|
|
42
|
+
|| SOURCE_RANK[a.c.source] - SOURCE_RANK[b.c.source]
|
|
43
|
+
|| a.c.name.localeCompare(b.c.name))
|
|
44
|
+
.map((x) => x.c);
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { shouldSubmitOnEnter, COARSE_POINTER_QUERY } from './composer-behaviour';
|
|
3
|
+
|
|
4
|
+
const k = (key: string, shiftKey = false) => ({ key, shiftKey });
|
|
5
|
+
|
|
6
|
+
describe('shouldSubmitOnEnter', () => {
|
|
7
|
+
it('sends on Enter where there is a real keyboard', () => {
|
|
8
|
+
expect(shouldSubmitOnEnter(k('Enter'), false)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// On a touch device the on-screen Enter is conventionally a newline, and a
|
|
12
|
+
// stray one would fire a half-typed message.
|
|
13
|
+
it('never sends on Enter on a coarse pointer', () => {
|
|
14
|
+
expect(shouldSubmitOnEnter(k('Enter'), true)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('Shift+Enter is always a newline', () => {
|
|
18
|
+
expect(shouldSubmitOnEnter(k('Enter', true), false)).toBe(false);
|
|
19
|
+
expect(shouldSubmitOnEnter(k('Enter', true), true)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('ignores every other key', () => {
|
|
23
|
+
for (const key of ['a', 'Escape', 'Tab', 'NumpadEnter']) {
|
|
24
|
+
expect(shouldSubmitOnEnter(k(key), false)).toBe(false);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('exposes the media query it is paired with', () => {
|
|
29
|
+
expect(COARSE_POINTER_QUERY).toBe('(pointer: coarse)');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The compact key bar exists for a device with a finger and no real keyboard.
|
|
2
|
+
// That is a pointer question, not a width one: a half-width desktop window is
|
|
3
|
+
// not a phone, and an iPad in landscape is not a desktop.
|
|
4
|
+
export const COARSE_POINTER_QUERY = '(pointer: coarse)';
|
|
5
|
+
|
|
6
|
+
// Enter sends where there is a real keyboard. On an on-screen keyboard the
|
|
7
|
+
// Enter key is conventionally a newline, and a stray one would fire a
|
|
8
|
+
// half-typed message — so there the send button is the only way to send.
|
|
9
|
+
//
|
|
10
|
+
// The second argument is deliberately "on-screen keyboard only", not "coarse
|
|
11
|
+
// pointer". They are not the same thing: an iPad with a keyboard cover is a
|
|
12
|
+
// coarse pointer WITH a real Enter key, and treating it as a phone made Enter
|
|
13
|
+
// useless there. The caller combines the pointer test with the hardware
|
|
14
|
+
// inference in useHardwareKeyboard.
|
|
15
|
+
export function shouldSubmitOnEnter(
|
|
16
|
+
e: { key: string; shiftKey: boolean },
|
|
17
|
+
onScreenKeyboardOnly: boolean
|
|
18
|
+
): boolean {
|
|
19
|
+
if (e.key !== 'Enter') return false;
|
|
20
|
+
if (e.shiftKey) return false; // Shift+Enter is a newline everywhere
|
|
21
|
+
return !onScreenKeyboardOnly;
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The one shape the whole feature speaks. The browser never learns which
|
|
2
|
+
// vendor produced a session, which is the entire point of the adapter layer.
|
|
3
|
+
|
|
4
|
+
export type AiStatus = 'working' | 'waiting_input' | 'waiting_approval' | 'idle';
|
|
5
|
+
|
|
6
|
+
export type AiEvent =
|
|
7
|
+
| { kind: 'message'; role: 'user' | 'assistant'; text: string; id?: string; at?: string }
|
|
8
|
+
| { kind: 'tool_call'; name: string; summary: string; input?: unknown; id?: string; at?: string }
|
|
9
|
+
| { kind: 'tool_result'; forId: string; ok: boolean; preview: string; at?: string }
|
|
10
|
+
| { kind: 'thinking'; text: string; id?: string; at?: string }
|
|
11
|
+
| { kind: 'state'; status: AiStatus; at?: string };
|
|
12
|
+
|
|
13
|
+
export interface AiAdapter {
|
|
14
|
+
// Never throws. An unrecognised record yields [].
|
|
15
|
+
toEvents(record: unknown): AiEvent[];
|
|
16
|
+
// A display title if this record carries one, else null.
|
|
17
|
+
title(record: unknown): string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Tool output is routinely megabytes (a full test run, a large file read).
|
|
21
|
+
// Only a preview crosses the socket; the full text is never needed to follow
|
|
22
|
+
// a conversation, and shipping it would stall a phone on mobile data.
|
|
23
|
+
export const PREVIEW_LIMIT = 2000;
|
|
24
|
+
|
|
25
|
+
export function preview(text: string): string {
|
|
26
|
+
return text.length <= PREVIEW_LIMIT ? text : `${text.slice(0, PREVIEW_LIMIT)}\n…truncated`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type { AiKind } from './kinds';
|
|
2
|
+
|
|
3
|
+
export type { AiStatus, AiEvent, AiAdapter } from './events';
|
|
4
|
+
export { PREVIEW_LIMIT, preview } from './events';
|
|
5
|
+
|
|
6
|
+
export type { PermissionMode } from './permission';
|
|
7
|
+
export { PERMISSION_MODES, isPermissionMode } from './permission';
|
|
8
|
+
|
|
9
|
+
export type { AiSessionSummary } from './session';
|
|
10
|
+
export { REPLAY_LIMIT } from './session';
|
|
11
|
+
|
|
12
|
+
export type { CommandSource, AiCommand, McpServerInfo, AiCapabilities } from './capabilities';
|
|
13
|
+
|
|
14
|
+
export { NOTIFY_COOLDOWN_MS, shouldNotifyAi } from './notify';
|
|
15
|
+
|
|
16
|
+
export type { ToolItem, ChatBlock } from './chat-blocks';
|
|
17
|
+
export { groupEvents } from './chat-blocks';
|
|
18
|
+
|
|
19
|
+
export { PIN_THRESHOLD_PX, shouldFollow } from './scroll-pin';
|
|
20
|
+
|
|
21
|
+
export type { ToolClass, ToolAppearance } from './tool-appearance';
|
|
22
|
+
export { toolClass, toolAppearance } from './tool-appearance';
|
|
23
|
+
|
|
24
|
+
export { commandQuery, rankCommands } from './command-filter';
|
|
25
|
+
|
|
26
|
+
export { COARSE_POINTER_QUERY, shouldSubmitOnEnter } from './composer-behaviour';
|
|
27
|
+
|
|
28
|
+
export type { PrefStore } from './ai-chat-prefs';
|
|
29
|
+
export { FUNCTION_KEYS_PREF, getFunctionKeysVisible, setFunctionKeysVisible } from './ai-chat-prefs';
|
|
30
|
+
|
|
31
|
+
export type { TerminalNameVars } from './terminal-name';
|
|
32
|
+
export { DEFAULT_TERMINAL_TEMPLATE, TERMINAL_NAME_TOKENS, factsFromSession, renderTerminalName } from './terminal-name';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Vendors we know today. A client that switches exhaustively on this will
|
|
2
|
+
// break the week a fourth is added — treat an unknown kind as generic at
|
|
3
|
+
// runtime (string on the wire, this union at compile time for the ones we
|
|
4
|
+
// have adapters for).
|
|
5
|
+
export type AiKind = 'claude' | 'codex' | 'grok';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { shouldNotifyAi } from './notify';
|
|
3
|
+
|
|
4
|
+
// This is the logic most capable of annoying someone into turning
|
|
5
|
+
// notifications off, so every rule gets a case.
|
|
6
|
+
describe('shouldNotifyAi', () => {
|
|
7
|
+
const NOW = 1_000_000;
|
|
8
|
+
|
|
9
|
+
it('fires when a working session starts waiting for input', () => {
|
|
10
|
+
expect(shouldNotifyAi('working', 'waiting_input', undefined, NOW)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('fires when a working session starts waiting for approval', () => {
|
|
14
|
+
expect(shouldNotifyAi('working', 'waiting_approval', undefined, NOW)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('stays silent while the status is unchanged', () => {
|
|
18
|
+
// Without this the agent's five-second poll would re-notify forever.
|
|
19
|
+
expect(shouldNotifyAi('waiting_input', 'waiting_input', undefined, NOW)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('stays silent for a session seen for the first time', () => {
|
|
23
|
+
// It was probably already sitting there before anyone subscribed;
|
|
24
|
+
// announcing it would mean a burst on every reconnect.
|
|
25
|
+
expect(shouldNotifyAi(undefined, 'waiting_input', undefined, NOW)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('stays silent when a session goes back to working', () => {
|
|
29
|
+
expect(shouldNotifyAi('waiting_input', 'working', undefined, NOW)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('stays silent when a session merely goes idle', () => {
|
|
33
|
+
// Idle is not "your turn" — nobody needs waking for it.
|
|
34
|
+
expect(shouldNotifyAi('working', 'idle', undefined, NOW)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not fire on idle -> waiting_input', () => {
|
|
38
|
+
// Only work that was actually running is worth being pulled back to.
|
|
39
|
+
expect(shouldNotifyAi('idle', 'waiting_input', undefined, NOW)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('respects the cooldown so a run of short tool calls cannot spam', () => {
|
|
43
|
+
expect(shouldNotifyAi('working', 'waiting_input', NOW - 30_000, NOW)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('fires again once the cooldown has passed', () => {
|
|
47
|
+
expect(shouldNotifyAi('working', 'waiting_input', NOW - 200_000, NOW)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// When an AI session change is worth interrupting someone for.
|
|
2
|
+
//
|
|
3
|
+
// Pure so it can be tested without starting a server. This is the logic most
|
|
4
|
+
// capable of annoying a user into switching notifications off, so it is
|
|
5
|
+
// deliberately conservative.
|
|
6
|
+
|
|
7
|
+
export const NOTIFY_COOLDOWN_MS = 120_000;
|
|
8
|
+
|
|
9
|
+
const WAITING = new Set(['waiting_input', 'waiting_approval']);
|
|
10
|
+
|
|
11
|
+
export function shouldNotifyAi(
|
|
12
|
+
previous: string | undefined,
|
|
13
|
+
next: string,
|
|
14
|
+
lastNotifiedAt: number | undefined,
|
|
15
|
+
nowMs: number
|
|
16
|
+
): boolean {
|
|
17
|
+
// Nothing to say about a session we are seeing for the first time: it was
|
|
18
|
+
// probably already sitting there before anyone subscribed, and announcing
|
|
19
|
+
// it would mean a burst on every agent reconnect.
|
|
20
|
+
if (previous === undefined) return false;
|
|
21
|
+
if (previous === next) return false;
|
|
22
|
+
// Only a transition INTO waiting counts. Notifying on the state itself
|
|
23
|
+
// would re-fire every five seconds for as long as the session sat there.
|
|
24
|
+
if (!WAITING.has(next)) return false;
|
|
25
|
+
// Only work that was actually running is worth being pulled back to; idle
|
|
26
|
+
// sessions drifting into 'waiting' are not news.
|
|
27
|
+
if (previous !== 'working') return false;
|
|
28
|
+
if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Trust levels offered when launching a session. Order is safest-first.
|
|
2
|
+
// `bypassPermissions` is deliberately absent — nothing arriving from a phone
|
|
3
|
+
// should be able to disable every permission check.
|
|
4
|
+
export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'dontAsk';
|
|
5
|
+
|
|
6
|
+
export const PERMISSION_MODES: PermissionMode[] = ['plan', 'default', 'acceptEdits', 'dontAsk'];
|
|
7
|
+
|
|
8
|
+
export function isPermissionMode(value: string): value is PermissionMode {
|
|
9
|
+
return (PERMISSION_MODES as string[]).includes(value);
|
|
10
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { shouldFollow, PIN_THRESHOLD_PX } from './scroll-pin';
|
|
3
|
+
|
|
4
|
+
describe('shouldFollow', () => {
|
|
5
|
+
it('follows when parked at the bottom', () => {
|
|
6
|
+
expect(shouldFollow(900, 1400, 500)).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('follows at exactly the threshold, and stops one pixel past it', () => {
|
|
10
|
+
expect(shouldFollow(900 - PIN_THRESHOLD_PX, 1400, 500)).toBe(true);
|
|
11
|
+
expect(shouldFollow(900 - PIN_THRESHOLD_PX - 1, 1400, 500)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('does not follow when the reader has scrolled up', () => {
|
|
15
|
+
expect(shouldFollow(0, 1400, 500)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Content shorter than the viewport is always "at the bottom": there is
|
|
19
|
+
// nowhere to scroll, so a new event must still land in view.
|
|
20
|
+
it('follows when content is shorter than the viewport', () => {
|
|
21
|
+
expect(shouldFollow(0, 300, 500)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// A terminal follows the tail unless you have scrolled away from it. Pulled
|
|
2
|
+
// out of the component so the threshold is a tested constant rather than a
|
|
3
|
+
// number buried in an effect.
|
|
4
|
+
export const PIN_THRESHOLD_PX = 80;
|
|
5
|
+
|
|
6
|
+
export function shouldFollow(
|
|
7
|
+
scrollTop: number,
|
|
8
|
+
scrollHeight: number,
|
|
9
|
+
clientHeight: number,
|
|
10
|
+
threshold: number = PIN_THRESHOLD_PX
|
|
11
|
+
): boolean {
|
|
12
|
+
return scrollHeight - scrollTop - clientHeight <= threshold;
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AiKind } from './kinds';
|
|
2
|
+
import type { AiStatus } from './events';
|
|
3
|
+
import type { PermissionMode } from './permission';
|
|
4
|
+
|
|
5
|
+
export interface AiSessionSummary {
|
|
6
|
+
tmuxName: string;
|
|
7
|
+
kind: AiKind;
|
|
8
|
+
title: string;
|
|
9
|
+
folder: string;
|
|
10
|
+
folderPath: string;
|
|
11
|
+
gitBranch: string;
|
|
12
|
+
profile: string;
|
|
13
|
+
status: AiStatus;
|
|
14
|
+
// 'tmux' = you started it in your own terminal; 'launched' = DevDash did.
|
|
15
|
+
origin: 'tmux' | 'launched';
|
|
16
|
+
permissionMode?: PermissionMode;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
// Which file this row was built from, so opening the session resolves to
|
|
19
|
+
// the same one rather than re-racing the other panes.
|
|
20
|
+
transcript: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Replay cap. Opening a month-old session must not flood the socket, and a
|
|
24
|
+
// phone cannot render ten thousand bubbles anyway.
|
|
25
|
+
export const REPLAY_LIMIT = 200;
|