hn-bits 0.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.
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export const DEFAULT_OLLAMA_CONFIG = {
5
+ host: 'http://localhost:11434',
6
+ model: 'llama3.2',
7
+ };
8
+ const DEFAULTS = {
9
+ ollama: DEFAULT_OLLAMA_CONFIG,
10
+ };
11
+ /** Shown in the summary panel / Ask AI view when no config file is present. */
12
+ export const AI_SETUP_HINT_LINES = [
13
+ 'AI not configured.',
14
+ '1. Install Ollama and pull a model: ollama pull llama3.2',
15
+ '2. Create ~/.config/hn-bits/config.json:',
16
+ ' { "ollama": { "host": "http://localhost:11434", "model": "llama3.2" } }',
17
+ ];
18
+ export function configPath() {
19
+ return process.env.HN_BITS_CONFIG || join(homedir(), '.config', 'hn-bits', 'config.json');
20
+ }
21
+ function readConfigFile(path) {
22
+ try {
23
+ return readFileSync(path, 'utf-8');
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** Loads AI config from disk. Returns null if absent; never throws — invalid JSON warns and falls back to absent. */
30
+ export function loadConfig() {
31
+ const raw = readConfigFile(configPath());
32
+ if (raw == null)
33
+ return null;
34
+ try {
35
+ const parsed = JSON.parse(raw);
36
+ return {
37
+ ollama: {
38
+ host: parsed.ollama?.host ?? DEFAULTS.ollama.host,
39
+ model: parsed.ollama?.model ?? DEFAULTS.ollama.model,
40
+ },
41
+ telegram: parsed.telegram,
42
+ desktopNotifications: parsed.desktopNotifications && {
43
+ enabled: parsed.desktopNotifications.enabled,
44
+ timeoutSeconds: parsed.desktopNotifications.timeoutSeconds ?? 10,
45
+ },
46
+ };
47
+ }
48
+ catch (err) {
49
+ console.warn(`config invalid: ${err.message} — AI disabled`);
50
+ return null;
51
+ }
52
+ }
@@ -0,0 +1,31 @@
1
+ export const CONFIG_KEYS = {
2
+ 'ollama.host': { path: ['ollama', 'host'], type: 'string' },
3
+ 'ollama.model': { path: ['ollama', 'model'], type: 'string' },
4
+ 'telegram.enabled': { path: ['telegram', 'enabled'], type: 'boolean' },
5
+ 'telegram.botToken': { path: ['telegram', 'botToken'], type: 'string', sensitive: true },
6
+ 'telegram.chatId': { path: ['telegram', 'chatId'], type: 'string' },
7
+ 'desktopNotifications.enabled': { path: ['desktopNotifications', 'enabled'], type: 'boolean' },
8
+ 'desktopNotifications.timeoutSeconds': { path: ['desktopNotifications', 'timeoutSeconds'], type: 'number' },
9
+ };
10
+ /** Coerces a CLI string argument to the key's declared type. Throws on invalid input. */
11
+ export function parseValue(def, raw) {
12
+ if (def.type === 'boolean') {
13
+ if (raw !== 'true' && raw !== 'false')
14
+ throw new Error(`expected 'true' or 'false', got '${raw}'`);
15
+ return raw === 'true';
16
+ }
17
+ if (def.type === 'number') {
18
+ const value = Number(raw);
19
+ if (Number.isNaN(value))
20
+ throw new Error(`expected a number, got '${raw}'`);
21
+ return value;
22
+ }
23
+ return raw;
24
+ }
25
+ /** Renders a value for `config list`; sensitive values are masked. */
26
+ export function formatValue(def, value) {
27
+ const rendered = String(value);
28
+ if (!def.sensitive)
29
+ return rendered;
30
+ return `${rendered.slice(0, 6)}…(hidden)`;
31
+ }
@@ -0,0 +1,64 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { configPath, DEFAULT_OLLAMA_CONFIG, loadConfig } from './config.js';
4
+ import { CONFIG_KEYS, formatValue, parseValue } from './configKeys.js';
5
+ function requireKeyDef(key) {
6
+ const def = CONFIG_KEYS[key];
7
+ if (!def)
8
+ throw new Error(`unknown config key: '${key}'`);
9
+ return def;
10
+ }
11
+ /** Raw parsed config, never merged with defaults — {} if the file is absent or invalid. */
12
+ export function readRawConfig() {
13
+ try {
14
+ return JSON.parse(readFileSync(configPath(), 'utf-8'));
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ export function writeRawConfig(config) {
21
+ const path = configPath();
22
+ mkdirSync(dirname(path), { recursive: true });
23
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
24
+ }
25
+ function resolveValue(key) {
26
+ const [section, field] = requireKeyDef(key).path;
27
+ const merged = loadConfig();
28
+ if (section === 'ollama') {
29
+ const ollama = merged?.ollama ?? DEFAULT_OLLAMA_CONFIG;
30
+ return ollama[field];
31
+ }
32
+ const sectionValue = merged?.[section];
33
+ return sectionValue?.[field];
34
+ }
35
+ /** Raw, unmasked value for `config get` — meant to be scripted/piped. */
36
+ export function getConfigValue(key) {
37
+ const value = resolveValue(key);
38
+ return value === undefined ? undefined : String(value);
39
+ }
40
+ export function setConfigValue(key, rawValue) {
41
+ const def = requireKeyDef(key);
42
+ const value = parseValue(def, rawValue);
43
+ const [section, field] = def.path;
44
+ const config = readRawConfig();
45
+ config[section] = { ...config[section], [field]: value };
46
+ writeRawConfig(config);
47
+ }
48
+ export function unsetConfigValue(key) {
49
+ const [section, field] = requireKeyDef(key).path;
50
+ const config = readRawConfig();
51
+ const sectionValue = config[section];
52
+ if (!sectionValue)
53
+ return;
54
+ delete sectionValue[field];
55
+ if (Object.keys(sectionValue).length === 0)
56
+ delete config[section];
57
+ writeRawConfig(config);
58
+ }
59
+ export function listConfigEntries() {
60
+ return Object.entries(CONFIG_KEYS).map(([key, def]) => {
61
+ const value = resolveValue(key);
62
+ return { key, value: value === undefined ? undefined : formatValue(def, value) };
63
+ });
64
+ }
@@ -0,0 +1,18 @@
1
+ // Wrapped lines have no internal newlines and rarely straddle a URL/email boundary;
2
+ // when they do, only the head of the match (up to the wrap point) gets colored.
3
+ const CONTACT_PATTERN = /(https?:\/\/\S+)|([\w.+-]+@[\w-]+\.[\w.-]+)/g;
4
+ /** Splits a single line of already-wrapped text into plain/link/email tokens. */
5
+ export function tokenizeContacts(line) {
6
+ const tokens = [];
7
+ let lastIndex = 0;
8
+ for (const match of line.matchAll(CONTACT_PATTERN)) {
9
+ const index = match.index;
10
+ if (index > lastIndex)
11
+ tokens.push({ text: line.slice(lastIndex, index) });
12
+ tokens.push({ text: match[0], kind: match[1] ? 'link' : 'email' });
13
+ lastIndex = index + match[0].length;
14
+ }
15
+ if (lastIndex < line.length)
16
+ tokens.push({ text: line.slice(lastIndex) });
17
+ return tokens.length > 0 ? tokens : [{ text: line }];
18
+ }
@@ -0,0 +1,20 @@
1
+ const MINUTE = 60;
2
+ const HOUR = 60 * MINUTE;
3
+ const DAY = 24 * HOUR;
4
+ export function formatAge(unixSeconds, now = new Date()) {
5
+ const delta = Math.floor(now.getTime() / 1000) - unixSeconds;
6
+ if (delta < HOUR)
7
+ return `${Math.floor(delta / MINUTE)}m`;
8
+ if (delta < DAY)
9
+ return `${Math.floor(delta / HOUR)}h`;
10
+ return `${Math.floor(delta / DAY)}d`;
11
+ }
12
+ export function truncateTitle(title, maxWidth) {
13
+ if (maxWidth <= 0)
14
+ return '';
15
+ if (title.length <= maxWidth)
16
+ return title;
17
+ if (maxWidth === 1)
18
+ return '…';
19
+ return `${title.slice(0, maxWidth - 1)}…`;
20
+ }
@@ -0,0 +1,37 @@
1
+ const ENTITIES = {
2
+ '&amp;': '&',
3
+ '&gt;': '>',
4
+ '&lt;': '<',
5
+ '&quot;': '"',
6
+ "&#x27;": "'",
7
+ '&#x2F;': '/',
8
+ };
9
+ function decodeEntities(text) {
10
+ return text.replace(/&amp;|&gt;|&lt;|&quot;|&#x27;|&#x2F;/g, (match) => ENTITIES[match]);
11
+ }
12
+ function extractCodeBlocks(html) {
13
+ return html.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/gi, (_match, code) => code
14
+ .split('\n')
15
+ .map((line) => ` ${line}`)
16
+ .join('\n'));
17
+ }
18
+ function extractAnchors(html) {
19
+ return html.replace(/<a\s+href="([^"]*)"[^>]*>.*?<\/a>/gi, '$1');
20
+ }
21
+ function stripTags(html) {
22
+ return html.replace(/<\/?i>/gi, '').replace(/<p>/gi, '\n\n').replace(/<[^>]+>/g, '');
23
+ }
24
+ function prefixQuotedLines(text) {
25
+ return text
26
+ .split('\n')
27
+ .map((line) => (line.startsWith('>') ? `│ ${line.slice(1).trim()}` : line))
28
+ .join('\n');
29
+ }
30
+ /** Renders HN comment HTML as plain text. Dumb and total — never throws. */
31
+ export function htmlToText(html) {
32
+ const withCode = extractCodeBlocks(html);
33
+ const withUrls = extractAnchors(withCode);
34
+ const stripped = stripTags(withUrls);
35
+ const decoded = decodeEntities(stripped);
36
+ return prefixQuotedLines(decoded).replace(/^\n+|\n+$/g, '');
37
+ }
@@ -0,0 +1,18 @@
1
+ export function clampSelection(current, delta, length) {
2
+ if (length === 0)
3
+ return 0;
4
+ return Math.min(Math.max(current + delta, 0), length - 1);
5
+ }
6
+ const FEED_KEYS = { t: 'top', n: 'new', b: 'best' };
7
+ export function mapFeedKey(key) {
8
+ return FEED_KEYS[key];
9
+ }
10
+ const FEED_ORDER = ['top', 'new', 'best', 'ask', 'show'];
11
+ export function nextFeed(current) {
12
+ const index = FEED_ORDER.indexOf(current);
13
+ return FEED_ORDER[(index + 1) % FEED_ORDER.length];
14
+ }
15
+ export function previousFeed(current) {
16
+ const index = FEED_ORDER.indexOf(current);
17
+ return FEED_ORDER[(index - 1 + FEED_ORDER.length) % FEED_ORDER.length];
18
+ }
@@ -0,0 +1,10 @@
1
+ export function getHostname(url) {
2
+ if (!url)
3
+ return undefined;
4
+ try {
5
+ return new URL(url).hostname;
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
@@ -0,0 +1,81 @@
1
+ // Clamp/advance the window start so `selected` stays visible.
2
+ // Called every render with the current height, so terminal shrink is handled for free.
3
+ export function ensureVisible(offset, selected, height, count) {
4
+ const maxOffset = Math.max(0, count - height);
5
+ let next = offset;
6
+ if (selected < next)
7
+ next = selected;
8
+ if (selected >= next + height)
9
+ next = selected - height + 1;
10
+ return Math.min(Math.max(next, 0), maxOffset);
11
+ }
12
+ export function visibleSlice(items, offset, height) {
13
+ return items.slice(offset, offset + height);
14
+ }
15
+ export function shouldFetchMore(selected, fetchedCount, totalCount, threshold) {
16
+ return fetchedCount < totalCount && selected >= fetchedCount - threshold;
17
+ }
18
+ // Deterministic greedy word-wrap; words longer than `width` are hard-broken.
19
+ export function wrapPlainText(text, width) {
20
+ const safeWidth = Math.max(1, width);
21
+ return text.split('\n').flatMap((line) => wrapLine(line, safeWidth));
22
+ }
23
+ function wrapLine(line, width) {
24
+ const rows = [];
25
+ let current = '';
26
+ for (const word of line.split(' ')) {
27
+ const candidate = current.length === 0 ? word : `${current} ${word}`;
28
+ if (candidate.length <= width) {
29
+ current = candidate;
30
+ continue;
31
+ }
32
+ if (current.length > 0)
33
+ rows.push(current);
34
+ current = word;
35
+ while (current.length > width) {
36
+ rows.push(current.slice(0, width));
37
+ current = current.slice(width);
38
+ }
39
+ }
40
+ if (current.length > 0)
41
+ rows.push(current);
42
+ return rows.length > 0 ? rows : [''];
43
+ }
44
+ function lineStart(heights, index) {
45
+ return heights.slice(0, index).reduce((sum, h) => sum + h, 0);
46
+ }
47
+ function totalLines(heights) {
48
+ return heights.reduce((sum, h) => sum + h, 0);
49
+ }
50
+ // Line-based analogue of ensureVisible: keeps the selected row's first line visible.
51
+ export function ensureVisibleLines(heights, selected, topLine, viewportLines) {
52
+ const start = lineStart(heights, selected);
53
+ const maxTop = Math.max(0, totalLines(heights) - viewportLines);
54
+ let next = topLine;
55
+ if (start < next)
56
+ next = start;
57
+ if (start >= next + viewportLines)
58
+ next = start - viewportLines + 1;
59
+ return Math.min(Math.max(next, 0), maxTop);
60
+ }
61
+ // Which rows intersect [topLine, topLine + viewportLines), and how many lines to clip at each edge.
62
+ export function sliceByLines(heights, topLine, viewportLines) {
63
+ const empty = { first: 0, last: -1, clipTop: 0, clipBottom: 0 };
64
+ if (heights.length === 0 || viewportLines <= 0)
65
+ return empty;
66
+ const starts = heights.map((_, i) => lineStart(heights, i));
67
+ const total = totalLines(heights);
68
+ const windowStart = Math.max(0, Math.min(topLine, total));
69
+ const windowEnd = Math.min(total, windowStart + viewportLines);
70
+ if (windowEnd <= windowStart)
71
+ return empty;
72
+ let first = 0;
73
+ while (first < heights.length && starts[first] + heights[first] <= windowStart)
74
+ first++;
75
+ let last = first;
76
+ while (last + 1 < heights.length && starts[last + 1] < windowEnd)
77
+ last++;
78
+ const clipTop = windowStart - starts[first];
79
+ const clipBottom = Math.max(0, starts[last] + heights[last] - windowEnd);
80
+ return { first, last, clipTop, clipBottom };
81
+ }
package/dist/ui/App.js ADDED
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { useApp, useInput } from 'ink';
4
+ import { loadConfig } from '../lib/config.js';
5
+ import { AskAI } from './AskAI.js';
6
+ import { Comments } from './Comments.js';
7
+ import { HelpOverlay } from './HelpOverlay.js';
8
+ import { COMMENTS_KEYS, LIST_KEYS, SEARCH_RESULTS_KEYS, footerHint } from './keymap.js';
9
+ import { Body, Footer, Header, Screen } from './Layout.js';
10
+ import { SearchInput } from './SearchInput.js';
11
+ import { SearchResults } from './SearchResults.js';
12
+ import { StoryList } from './StoryList.js';
13
+ import { TabBar } from './TabBar.js';
14
+ export function App({ initialQuery }) {
15
+ const [feed, setFeed] = useState('top');
16
+ const [view, setView] = useState(initialQuery ? { name: 'search', query: initialQuery, from: 'cli' } : { name: 'list' });
17
+ const [helpOpen, setHelpOpen] = useState(false);
18
+ const [config] = useState(loadConfig);
19
+ const { exit } = useApp();
20
+ useInput((input) => {
21
+ if (helpOpen)
22
+ return setHelpOpen(false);
23
+ if (view.name === 'search-input' || view.name === 'ask')
24
+ return;
25
+ if (input === 'q')
26
+ return exit();
27
+ if (input === '?')
28
+ return setHelpOpen(true);
29
+ });
30
+ const ctx = { feed, config, setFeed, setView, exit };
31
+ return (_jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: feed }) }), _jsx(Body, { children: helpOpen ? _jsx(HelpOverlay, { ...helpFor(view) }) : renderBody(view, ctx) }), _jsx(Footer, { children: renderFooter(view, ctx) })] }));
32
+ }
33
+ function renderBody(view, ctx) {
34
+ if (view.name === 'list')
35
+ return renderList(ctx);
36
+ if (view.name === 'search')
37
+ return renderSearch(view, ctx);
38
+ if (view.name === 'search-input')
39
+ return null;
40
+ if (view.name === 'ask')
41
+ return renderAskAI(view, ctx);
42
+ return renderComments(view, ctx);
43
+ }
44
+ function renderFooter(view, ctx) {
45
+ if (view.name === 'search-input') {
46
+ return (_jsx(SearchInput, { onSubmit: (query) => ctx.setView({ name: 'search', query, from: view.from }), onCancel: () => ctx.setView({ name: 'list' }) }));
47
+ }
48
+ if (view.name === 'ask')
49
+ return null;
50
+ if (view.name === 'list')
51
+ return footerHint(LIST_KEYS);
52
+ if (view.name === 'comments')
53
+ return footerHint(COMMENTS_KEYS);
54
+ return footerHint(SEARCH_RESULTS_KEYS);
55
+ }
56
+ function helpFor(view) {
57
+ if (view.name === 'comments')
58
+ return { title: 'comments', keys: COMMENTS_KEYS };
59
+ if (view.name === 'search')
60
+ return { title: 'search results', keys: SEARCH_RESULTS_KEYS };
61
+ return { title: 'story list', keys: LIST_KEYS };
62
+ }
63
+ function renderList({ feed, config, setFeed, setView }) {
64
+ return (_jsx(StoryList, { feed: feed, config: config, onFeedChange: setFeed, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'list' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'list' } }) }));
65
+ }
66
+ function renderSearch(view, { config, setView, exit }) {
67
+ const returnTo = { name: 'search', query: view.query, from: view.from };
68
+ return (_jsx(SearchResults, { query: view.query, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onExit: () => (view.from === 'tui' ? setView({ name: 'list' }) : exit()), onSearchAgain: () => setView({ name: 'search-input', from: view.from }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }) }));
69
+ }
70
+ function renderComments(view, { config, setView }) {
71
+ return (_jsx(Comments, { story: view.story, config: config, onBack: () => setView(view.returnTo), onAskAI: (comments) => setView({ name: 'ask', story: view.story, comments, returnTo: view }) }));
72
+ }
73
+ function renderAskAI(view, { config, setView }) {
74
+ return (_jsx(AskAI, { story: view.story, comments: view.comments, config: config, onBack: () => setView(view.returnTo) }));
75
+ }
@@ -0,0 +1,186 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Box, Text, useInput, useWindowSize } from 'ink';
4
+ import { buildAskAIContext } from '../ai/context.js';
5
+ import { chatStream, checkOllama, describeError } from '../ai/ollama.js';
6
+ import { fetchComments } from '../api/algolia.js';
7
+ import { ExtractionError, extractArticle } from '../lib/article.js';
8
+ import { AI_SETUP_HINT_LINES } from '../lib/config.js';
9
+ import { truncateTitle } from '../lib/format.js';
10
+ import { htmlToText } from '../lib/html.js';
11
+ import { wrapPlainText } from '../lib/viewport.js';
12
+ import { HEADER_ROWS } from './Layout.js';
13
+ import { theme } from './theme.js';
14
+ async function resolveArticle(story) {
15
+ if (story.url) {
16
+ try {
17
+ return { article: await extractArticle(story.url) };
18
+ }
19
+ catch (err) {
20
+ return { article: null, reason: err instanceof ExtractionError ? err.reason : 'unknown' };
21
+ }
22
+ }
23
+ if (story.text)
24
+ return { article: { text: htmlToText(story.text) } };
25
+ return { article: null, reason: 'text post' };
26
+ }
27
+ export function AskAI({ story, comments, config, onBack }) {
28
+ const { columns, rows } = useWindowSize();
29
+ const [status, setStatus] = useState(config ? 'preparing' : 'hint');
30
+ const [unavailableDetail, setUnavailableDetail] = useState('');
31
+ const [systemContext, setSystemContext] = useState('');
32
+ const [history, setHistory] = useState([]);
33
+ const [input, setInput] = useState('');
34
+ const [scrollOffset, setScrollOffset] = useState(0);
35
+ const controllerRef = useRef(null);
36
+ const token = useRef(0);
37
+ useEffect(() => {
38
+ if (config)
39
+ void prepare(config);
40
+ return () => controllerRef.current?.abort();
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ }, []);
43
+ async function prepare(cfg) {
44
+ const myToken = ++token.current;
45
+ setStatus('preparing');
46
+ const health = await checkOllama(cfg.ollama);
47
+ if (myToken !== token.current)
48
+ return;
49
+ if (!health.ok) {
50
+ setUnavailableDetail(health.detail);
51
+ setStatus('unavailable');
52
+ return;
53
+ }
54
+ const { article, reason } = await resolveArticle(story);
55
+ if (myToken !== token.current)
56
+ return;
57
+ let thread = comments;
58
+ if (thread == null) {
59
+ try {
60
+ thread = await fetchComments(story.id);
61
+ }
62
+ catch {
63
+ thread = [];
64
+ }
65
+ }
66
+ if (myToken !== token.current)
67
+ return;
68
+ setSystemContext(buildAskAIContext({ story, article, articleUnavailableReason: reason, comments: thread }));
69
+ setStatus('idle');
70
+ }
71
+ async function send() {
72
+ const text = input.trim();
73
+ if (!config || !text)
74
+ return;
75
+ setInput('');
76
+ setScrollOffset(0);
77
+ const messages = [
78
+ { role: 'system', content: systemContext },
79
+ ...history.map((turn) => ({ role: turn.role, content: turn.content })),
80
+ { role: 'user', content: text },
81
+ ];
82
+ setHistory((h) => [...h, { role: 'user', content: text }, { role: 'assistant', content: '' }]);
83
+ setStatus('streaming');
84
+ const controller = new AbortController();
85
+ controllerRef.current = controller;
86
+ const myToken = ++token.current;
87
+ function replaceLastAssistant(update) {
88
+ setHistory((h) => {
89
+ const copy = h.slice();
90
+ const last = copy[copy.length - 1];
91
+ if (last)
92
+ copy[copy.length - 1] = update(last);
93
+ return copy;
94
+ });
95
+ }
96
+ try {
97
+ let content = '';
98
+ for await (const delta of chatStream(config.ollama, messages, controller.signal)) {
99
+ if (myToken !== token.current)
100
+ return;
101
+ content += delta;
102
+ replaceLastAssistant((turn) => ({ ...turn, content }));
103
+ }
104
+ if (myToken === token.current)
105
+ setStatus('idle');
106
+ }
107
+ catch (err) {
108
+ if (myToken !== token.current)
109
+ return;
110
+ replaceLastAssistant((turn) => ({ ...turn, content: describeError(err) }));
111
+ setStatus('idle');
112
+ }
113
+ }
114
+ function abortStreaming() {
115
+ controllerRef.current?.abort();
116
+ token.current++;
117
+ setHistory((h) => {
118
+ const copy = h.slice();
119
+ const last = copy[copy.length - 1];
120
+ if (last?.role === 'assistant')
121
+ copy[copy.length - 1] = { ...last, cancelled: true };
122
+ return copy;
123
+ });
124
+ setStatus('idle');
125
+ }
126
+ useInput((rawInput, key) => {
127
+ if (status === 'streaming') {
128
+ if (key.escape)
129
+ abortStreaming();
130
+ return;
131
+ }
132
+ if (key.escape)
133
+ return onBack();
134
+ if (status !== 'idle')
135
+ return;
136
+ if (key.return)
137
+ return void send();
138
+ if (input === '' && key.upArrow)
139
+ return setScrollOffset((s) => s + 1);
140
+ if (input === '' && key.downArrow)
141
+ return setScrollOffset((s) => Math.max(0, s - 1));
142
+ if (key.backspace || key.delete)
143
+ return setInput((v) => v.slice(0, -1));
144
+ if (rawInput && !key.ctrl && !key.meta)
145
+ setInput((v) => v + rawInput);
146
+ });
147
+ const width = columns;
148
+ const headerRows = 2; // title line + blank
149
+ const dividerRows = 2; // blank + rule
150
+ const inputRows = 1;
151
+ const footerRows = 1;
152
+ // App's Footer box renders one more (empty) row below this view's own box — see App.tsx renderFooter.
153
+ const appFooterRows = 1;
154
+ const boxHeight = Math.max(1, rows - HEADER_ROWS - appFooterRows);
155
+ const historyRows = Math.max(1, boxHeight - headerRows - dividerRows - inputRows - footerRows);
156
+ const headerSuffix = status === 'preparing' ? ' · preparing…' : '';
157
+ const headerLabel = config
158
+ ? `ask · ${truncateTitle(story.title, Math.max(1, width - 20))} · ${config.ollama.model}${headerSuffix}`
159
+ : 'ask';
160
+ const historyLines = buildHistoryLines(history, width, status === 'streaming');
161
+ const maxScroll = Math.max(0, historyLines.length - historyRows);
162
+ const live = status === 'streaming';
163
+ const effectiveScroll = live ? maxScroll : Math.min(scrollOffset, maxScroll);
164
+ const start = Math.max(0, historyLines.length - historyRows - effectiveScroll);
165
+ const visibleLines = historyLines.slice(start, start + historyRows);
166
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: boxHeight, children: [_jsx(Text, { color: theme.colors.title, children: headerLabel }), _jsx(Text, { children: " " }), status === 'hint' &&
167
+ AI_SETUP_HINT_LINES.map((line) => (_jsx(Text, { dimColor: true, children: line }, line))), status === 'unavailable' && _jsx(Text, { color: theme.colors.error, children: unavailableDetail }), (status === 'idle' || status === 'streaming' || status === 'preparing') &&
168
+ visibleLines.map((line, i) => _jsx(Text, { children: line }, i)), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.max(1, width - 1)) }), _jsxs(Text, { dimColor: status !== 'idle', children: ['> ', input, status === 'idle' ? '▊' : ''] }), _jsx(Text, { dimColor: true, children: "enter send \u00B7 \u2191/\u2193 scroll \u00B7 esc back/abort \u00B7 ctrl+c quit" })] }));
169
+ }
170
+ function buildHistoryLines(history, width, live) {
171
+ const lines = [];
172
+ history.forEach((turn, i) => {
173
+ const label = turn.role === 'user' ? 'you ' : 'ai ';
174
+ const isLastAssistant = live && i === history.length - 1 && turn.role === 'assistant';
175
+ const text = isLastAssistant && !turn.content ? '▍(streaming)' : turn.content;
176
+ const wrapped = wrapPlainText(text, Math.max(1, width - label.length));
177
+ wrapped.forEach((line, j) => {
178
+ const suffix = isLastAssistant && j === wrapped.length - 1 && turn.content ? ' ▍' : '';
179
+ lines.push(`${j === 0 ? label : ' '.repeat(label.length)}${line}${suffix}`);
180
+ });
181
+ if (turn.cancelled)
182
+ lines.push(`${' '.repeat(label.length)}· cancelled`);
183
+ lines.push('');
184
+ });
185
+ return lines;
186
+ }