dsh-code 0.5.0 → 0.6.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/src/history.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Global input recall: persistent cross-session entries plus this process's
3
+ * submissions, with Codex `ChatComposerHistory` semantics — empty submissions
4
+ * are ignored, adjacent duplicates collapse, the recall space skips
5
+ * persistent entries that duplicate a local one (local wins), and Up/Down
6
+ * navigation is gated so interior cursor movement never hijacks the draft.
7
+ *
8
+ * @module @deepseek-ai/dsh-tui/history
9
+ */
10
+
11
+ /** Maximum entries retained in the persistent history file. */
12
+ export const HISTORY_MAX_ENTRIES = 500
13
+
14
+ /** Encode one entry for the history file (JSON keeps multi-line drafts intact). */
15
+ export function serializeHistoryEntry(text: string): string {
16
+ return JSON.stringify(text)
17
+ }
18
+
19
+ /**
20
+ * Parse a persisted history file (one JSON entry per line): invalid lines
21
+ * drop out, empty entries are ignored, adjacent duplicates collapse, and the
22
+ * result keeps only the newest `max` entries.
23
+ * @param raw - file content, empty for a missing file.
24
+ * @param max - entry cap.
25
+ * @returns persistent entries, oldest first.
26
+ */
27
+ export function parseHistoryFile(raw: string, max = HISTORY_MAX_ENTRIES): readonly string[] {
28
+ const kept: string[] = []
29
+ for (const line of raw.split('\n')) {
30
+ if (line === '') continue
31
+ let text: unknown
32
+ try {
33
+ text = JSON.parse(line)
34
+ } catch {
35
+ continue
36
+ }
37
+ if (typeof text !== 'string' || text === '') continue
38
+ if (kept.length > 0 && kept[kept.length - 1] === text) continue
39
+ kept.push(text)
40
+ }
41
+ return kept.slice(-max)
42
+ }
43
+
44
+ /**
45
+ * Append one entry to the persistent file content: JSON line, capped to the
46
+ * newest `max` entries with a trailing newline.
47
+ * @param current - existing file content.
48
+ * @param text - submission to persist.
49
+ * @param max - entry cap.
50
+ * @returns the new file content.
51
+ */
52
+ export function appendHistoryContent(current: string, text: string, max = HISTORY_MAX_ENTRIES): string {
53
+ const entries = [...parseHistoryFile(current, max), text].slice(-max)
54
+ return entries.map(serializeHistoryEntry).join('\n') + '\n'
55
+ }
56
+
57
+ /**
58
+ * Record one in-session submission: empty text is ignored and an adjacent
59
+ * duplicate collapses (Codex `record_local_submission` semantics).
60
+ * @param local - current in-session entries, oldest first.
61
+ * @param text - the submitted prompt.
62
+ * @returns the updated local list.
63
+ */
64
+ export function recordLocalEntry(local: readonly string[], text: string): readonly string[] {
65
+ if (text === '') return local
66
+ if (local.length > 0 && local[local.length - 1] === text) return local
67
+ return [...local, text]
68
+ }
69
+
70
+ /**
71
+ * Build the recall space, newest first: local entries, then persistent
72
+ * entries whose text is not duplicated locally (the local copy wins and the
73
+ * persistent twin is skipped — Codex's replay-seed dedup, applied to the
74
+ * whole local set).
75
+ * @param persistent - cross-session entries, oldest first.
76
+ * @param local - this process's submissions, oldest first.
77
+ * @returns recall entries, newest first.
78
+ */
79
+ export function recallEntries(persistent: readonly string[], local: readonly string[]): readonly string[] {
80
+ const localSet = new Set(local)
81
+ return [...persistent.filter(entry => !localSet.has(entry)), ...local].reverse()
82
+ }
83
+
84
+ /** Shell-style recall navigation over a fixed recall space. */
85
+ export interface RecallState {
86
+ /** Recall entries, newest first (frozen at navigation start). */
87
+ entries: readonly string[]
88
+ /** Current recall index; null when not browsing. */
89
+ index: number | null
90
+ /** Draft saved when browsing started; restored on Down past the newest. */
91
+ savedDraft: string
92
+ /** The recalled text currently in the composer (the boundary gate's anchor). */
93
+ lastRecalled: string | null
94
+ }
95
+
96
+ /** Fresh navigation state over one recall space. */
97
+ export function beginRecall(entries: readonly string[], draft: string): RecallState {
98
+ return { entries, index: null, savedDraft: draft, lastRecalled: null }
99
+ }
100
+
101
+ /** The outcome of one recall step. */
102
+ export interface RecallStep {
103
+ state: RecallState
104
+ /** The text to place in the composer; undefined means "no movement". */
105
+ entry: string | undefined
106
+ }
107
+
108
+ /**
109
+ * Move one entry older (Up, toward index +1 in the newest-first space). The
110
+ * first Up saves the current draft so Down past the newest can restore it
111
+ * (Claude-Code shell recall — the draft is never lost); the oldest entry
112
+ * stays put.
113
+ * @param state - current navigation state.
114
+ * @param draft - the composer text to preserve when browsing starts.
115
+ */
116
+ export function recallOlder(state: RecallState, draft: string): RecallStep {
117
+ if (state.index === null) {
118
+ const entry = state.entries[0]
119
+ if (entry === undefined) return { state, entry: undefined }
120
+ return { state: { ...state, index: 0, savedDraft: draft, lastRecalled: entry }, entry }
121
+ }
122
+ if (state.index >= state.entries.length - 1) return { state, entry: undefined }
123
+ const entry = state.entries[state.index + 1]
124
+ return { state: { ...state, index: state.index + 1, lastRecalled: entry }, entry }
125
+ }
126
+
127
+ /** Move one entry newer (Down, toward index 0); past the newest, browsing ends and the saved draft returns. */
128
+ export function recallNewer(state: RecallState): RecallStep {
129
+ if (state.index === null) return { state, entry: undefined }
130
+ const next = state.index - 1
131
+ if (next < 0) {
132
+ return { state: { ...state, index: null, lastRecalled: null }, entry: state.savedDraft }
133
+ }
134
+ const entry = state.entries[next]
135
+ return { state: { ...state, index: next, lastRecalled: entry }, entry }
136
+ }
package/src/index.ts CHANGED
@@ -11,15 +11,16 @@
11
11
 
12
12
  import { randomUUID } from 'node:crypto'
13
13
  import { readFileSync } from 'node:fs'
14
- import { writeFile as writeFileAsync } from 'node:fs/promises'
15
- import { basename, join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
16
+ import { basename, dirname, join } from 'node:path'
16
17
  import { createElement } from 'react'
17
18
  import type { Context } from '@deepseek-ai/cordis'
18
19
  import z from '@deepseek-ai/schemastery'
19
20
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
20
21
  import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
21
22
  import type {} from '@deepseek-ai/dsh-agent-default-model'
22
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
23
+ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
23
24
  import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
24
25
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
25
26
  // Type-only: carries the ctx.sessionTitle service merge for /title.
@@ -33,9 +34,11 @@ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
33
34
  import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
34
35
  import { internals, type TuiMount } from './internals.ts'
35
36
  import { loadModelDirectory, type ModelRow } from './models.ts'
36
- import { createMentions, type MentionsApi } from './mentions.ts'
37
+ import { createMentions, type MentionCandidate, type MentionsApi } from './mentions.ts'
37
38
  import { mountQuestionProvider, type QuestionStore } from './questions.ts'
38
- import { createTranscriptStore } from './store.ts'
39
+ import { createTranscriptStore, type TranscriptStore } from './store.ts'
40
+ import { parseStatuslineItems } from './render/status.ts'
41
+ import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
39
42
  import { watchSkills, type SkillsView } from './skills.ts'
40
43
  import { toolArgumentsPreview } from './render/tool-preview.ts'
41
44
  import { buildExportMarkdown } from './render/export.ts'
@@ -185,11 +188,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
185
188
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
186
189
 
187
190
  const cwd = process.cwd()
188
- const target = await resolveTarget(startup, persistence, cwd)
189
191
  const defaults = defaultModel.currentSelection()
190
192
  const presets = agentPresetsFrom(ctx)
191
193
  if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
192
194
 
195
+ // A bare fresh launch stays transient: no Agent or session is composed, and
196
+ // nothing is persisted, until the user's first real input. Explicit flags
197
+ // (--resume/--continue/--session/--mode) keep the eager create/resume path.
198
+ const lazy = startup.kind === 'fresh' && startup.mode === undefined
199
+
193
200
  interface ActiveSession {
194
201
  handle: AgentHandle
195
202
  agent: Agent
@@ -257,30 +264,42 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
257
264
  }
258
265
  }
259
266
 
260
- let active = await prepare(target)
261
- let agent = active.agent
262
- let session = active.session
263
- let store = active.store
264
- let mentions = active.mentions
267
+ let active: ActiveSession | undefined
268
+ let agent: Agent | undefined
269
+ let session: Session | undefined
270
+ let store: TranscriptStore = createTranscriptStore()
271
+ let mentions: MentionsApi | undefined
272
+
273
+ if (!lazy) {
274
+ const target = await resolveTarget(startup, persistence, cwd)
275
+ const prepared = await prepare(target)
276
+ active = prepared
277
+ agent = prepared.agent
278
+ session = prepared.session
279
+ store = prepared.store
280
+ mentions = prepared.mentions
281
+ }
265
282
 
266
283
  // Seed the transcript from the full session log: constructor seeds never
267
- // fire on `session/event`, so a resumed session paints its history once,
268
- // here, before the first render.
284
+ // fire on `session/event`, so a resumed session paints its history once
285
+ // before the first render. The handler reads the current session/store, so
286
+ // the deferred first session of a bare launch is covered by the same feed.
269
287
  const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
270
- if (subject.id === session.id) store.apply(event)
288
+ if (session !== undefined && subject.id === session.id) store.apply(event)
271
289
  })
272
290
 
273
291
  const commands: CommandsView = watchCommands(ctx)
274
- commands.setAgent(agent)
292
+ if (agent !== undefined) commands.setAgent(agent)
275
293
 
276
294
  const skills: SkillsView = watchSkills(ctx)
277
- skills.setAgent(agent)
295
+ if (agent !== undefined) skills.setAgent(agent)
278
296
 
279
297
  // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
280
- // claimed, every other ask falls through to the fail-closed waterfall.
298
+ // claimed, every other ask falls through to the fail-closed waterfall. The
299
+ // owner predicate is empty until the first session exists.
281
300
  const approval: ApprovalStore = mountApprovalAnswerer(
282
301
  ctx,
283
- candidate => candidate.id === agent.id,
302
+ candidate => agent !== undefined && candidate.id === agent.id,
284
303
  request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
285
304
  )
286
305
 
@@ -293,6 +312,71 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
293
312
  // process side (unknown commands, switch confirmations, cancels).
294
313
  const bridge: AppBridge = { notify: () => {} }
295
314
 
315
+ // /statusline persistence: one user-level JSON file under the DSH home.
316
+ // Missing file means defaults; a corrupt file degrades to defaults with a
317
+ // surfaced warning (the customization is user-authored, never silent).
318
+ const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
319
+ let statuslineWarning: string | undefined
320
+ let statuslineItems: readonly string[] = []
321
+ try {
322
+ statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
323
+ } catch (error) {
324
+ statuslineItems = parseStatuslineItems(undefined)
325
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
326
+ statuslineWarning = error instanceof Error ? error.message : String(error)
327
+ }
328
+ }
329
+ const saveStatusline = (items: readonly string[]): void => {
330
+ statuslineItems = [...items]
331
+ // The config directory may not exist on a first save; create it before
332
+ // the write so a fresh install persists customizations.
333
+ void mkdir(dirname(statuslinePath), { recursive: true })
334
+ .then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
335
+ .catch((writeError: unknown) => {
336
+ bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
337
+ })
338
+ }
339
+
340
+ // Global input recall (Codex composer-history contract): one JSONL file
341
+ // under the DSH home. A missing file means an empty history; unreadable or
342
+ // corrupt content degrades to the valid lines it could parse, silently —
343
+ // recall is a convenience surface, never a gate.
344
+ const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
345
+ let inputHistory: readonly string[] = []
346
+ try {
347
+ inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
348
+ } catch {
349
+ inputHistory = []
350
+ }
351
+ const recordHistory = (text: string): void => {
352
+ if (text === '') return
353
+ inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
354
+ // A missing file on the first save is not an error: start from empty.
355
+ let current = ''
356
+ try {
357
+ current = readFileSync(historyPath, 'utf8')
358
+ } catch {
359
+ current = ''
360
+ }
361
+ void mkdir(dirname(historyPath), { recursive: true })
362
+ .then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
363
+ .catch((writeError: unknown) => {
364
+ bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
365
+ })
366
+ }
367
+
368
+ /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
369
+ const cancelQueued = (messageId: string): void => {
370
+ if (agent === undefined) return
371
+ try {
372
+ if (agent.inbox.remove(MessageId(messageId))) {
373
+ bridge.notify('queued message cancelled')
374
+ }
375
+ } catch (error: unknown) {
376
+ bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
377
+ }
378
+ }
379
+
296
380
  // The mount handle lives in a box: quit closes over it, while the mount
297
381
  // itself is created after quit (the App element needs quit as a prop).
298
382
  const mountRef: { current?: TuiMount } = {}
@@ -303,13 +387,21 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
303
387
  switchQueue.cancel()
304
388
  off()
305
389
  mountRef.current?.unmount()
306
- void sessions.flush(session)
390
+ // A bare launch that exits before the first input has no session: exit
391
+ // cleanly without flushing or disposing anything.
392
+ const currentSession = session
393
+ const currentActive = active
394
+ if (currentSession === undefined || currentActive === undefined) {
395
+ io.exit(0)
396
+ return
397
+ }
398
+ void sessions.flush(currentSession)
307
399
  .catch((flushError: unknown) => {
308
400
  // The session log already carries every durable event; a failed flush
309
401
  // must not trap the user in a dead terminal, so report and still exit.
310
402
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
311
403
  })
312
- .then(() => active.handle.dispose())
404
+ .then(() => currentActive.handle.dispose())
313
405
  .catch((disposeError: unknown) => {
314
406
  internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
315
407
  })
@@ -318,6 +410,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
318
410
 
319
411
  /** Run one slash line through the command registry (closed namespace). */
320
412
  const runSlash = (line: string): void => {
413
+ const currentAgent = agent
414
+ if (currentAgent === undefined) return
321
415
  if (line.startsWith('/mode ')) {
322
416
  void switchModeAction(line.slice(6).trim())
323
417
  return
@@ -332,13 +426,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
332
426
  return
333
427
  }
334
428
  const controller = new AbortController()
335
- void Promise.resolve().then(() => registry.execute(agent, line, controller.signal)).then((execution) => {
429
+ void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
336
430
  if (execution === undefined) {
337
431
  // No command owns this line: send it verbatim so a user-invocable
338
432
  // skill gesture (`/skill-name`) reaches the host's tool-skill
339
433
  // pre-step injection — the web composer's same fall-through.
340
434
  try {
341
- agent.followup(createUserMessage({
435
+ currentAgent.followup(createUserMessage({
342
436
  content: [{ type: 'text', text: line }],
343
437
  source: { kind: 'user' },
344
438
  }))
@@ -351,10 +445,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
351
445
  })
352
446
  }
353
447
 
354
- /** Deliver one readable line to the agent, expanding session mentions first. */
355
- const send = (text: string, mode: 'followup' | 'steer'): void => {
356
- const line = text.trim()
357
- if (line === '') return
448
+ /** Deliver one trimmed line to the live session, expanding mentions first. */
449
+ const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
450
+ const currentAgent = agent!
451
+ const currentMentions = mentions!
358
452
  // The command registry is a closed namespace: slash lines run out of
359
453
  // band and never reach the model through this path (steering keeps the
360
454
  // registry out of the inbox, so slash lines steer as literal text).
@@ -362,9 +456,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
362
456
  runSlash(line)
363
457
  return
364
458
  }
365
- let parsed: ReturnType<typeof mentions.parse>
459
+ let parsed: ReturnType<MentionsApi['parse']>
366
460
  try {
367
- parsed = mentions.parse(line)
461
+ parsed = currentMentions.parse(line)
368
462
  } catch (error: unknown) {
369
463
  bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
370
464
  return
@@ -374,16 +468,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
374
468
  // the readable message (upstream README wiring: inject before the
375
469
  // followup/steer that wakes the driver).
376
470
  try {
377
- if (context !== undefined) agent.inject(context)
471
+ if (context !== undefined) currentAgent.inject(context)
378
472
  const message = createUserMessage({
379
473
  content: [{ type: 'text', text: readable }],
380
474
  source: { kind: 'user' },
381
475
  })
382
476
  if (mode === 'steer') {
383
- agent.steer(message)
384
- bridge.notify('steering queued the next step sees it')
477
+ // The queued message is visible as a pending transcript row (the
478
+ // web queue-mirror contract); no notice noise on the happy path.
479
+ currentAgent.steer(message)
385
480
  } else {
386
- agent.followup(message)
481
+ currentAgent.followup(message)
387
482
  }
388
483
  } catch (error: unknown) {
389
484
  bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
@@ -394,7 +489,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
394
489
  return
395
490
  }
396
491
  const controller = new AbortController()
397
- void mentions.prepare(parsed, controller.signal).then((prepared) => {
492
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
398
493
  deliver(prepared.text, prepared.additionalContext)
399
494
  }, (error: unknown) => {
400
495
  if (controller.signal.aborted) return
@@ -402,6 +497,56 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
402
497
  })
403
498
  }
404
499
 
500
+ // Deferred first-session creation for a bare launch: the session is composed
501
+ // only when the user submits real input (or /new), and every line that
502
+ // arrives during creation is delivered in order afterwards. A creation
503
+ // failure reports and clears the queue, leaving the transient state ready
504
+ // for the next attempt.
505
+ const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
506
+ let creating: Promise<void> | undefined
507
+ const ensureSession = (mode?: string): void => {
508
+ if (creating !== undefined) return
509
+ const attempt = (async () => {
510
+ const next = await prepare({
511
+ sessionId: `session-${randomUUID()}`,
512
+ resume: false,
513
+ ...(mode === undefined ? {} : { mode }),
514
+ })
515
+ if (quitting) {
516
+ void next.handle.dispose().catch(() => {})
517
+ return
518
+ }
519
+ active = next
520
+ agent = next.agent
521
+ session = next.session
522
+ store = next.store
523
+ mentions = next.mentions
524
+ commands.setAgent(agent)
525
+ skills.setAgent(agent)
526
+ renderCurrent()
527
+ const queued = pendingInputs.splice(0)
528
+ for (const item of queued) deliverLine(item.text, item.mode)
529
+ })().catch((error: unknown) => {
530
+ pendingInputs.length = 0
531
+ bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
532
+ }).finally(() => {
533
+ creating = undefined
534
+ })
535
+ creating = attempt
536
+ }
537
+
538
+ /** Deliver one readable line to the agent, expanding session mentions first. */
539
+ const send = (text: string, mode: 'followup' | 'steer'): void => {
540
+ const line = text.trim()
541
+ if (line === '') return
542
+ if (session === undefined) {
543
+ pendingInputs.push({ text: line, mode })
544
+ ensureSession()
545
+ return
546
+ }
547
+ deliverLine(line, mode)
548
+ }
549
+
405
550
  /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
406
551
  const dispatch = (text: string): void => {
407
552
  send(text, 'followup')
@@ -418,7 +563,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
418
563
 
419
564
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
420
565
  const interrupt = (): boolean => {
421
- if (agent.status !== 'running') return false
566
+ if (agent === undefined || agent.status !== 'running') return false
422
567
  try {
423
568
  agent.cancel({ kind: 'user' })
424
569
  bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
@@ -435,6 +580,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
435
580
  * custom knob state wraps to the first declared preset.
436
581
  */
437
582
  const cyclePermission = (): string => {
583
+ if (session === undefined) throw new Error('no session yet — submit a message to start')
438
584
  const service = ctx.get('permissionPresets') as
439
585
  | {
440
586
  names: readonly string[]
@@ -460,6 +606,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
460
606
 
461
607
  /** Apply one /model selection: takes effect from the next assembled step. */
462
608
  const selectModel = (row: ModelRow): string => {
609
+ if (active === undefined) throw new Error('no session yet — submit a message to start')
463
610
  active.selection.picked = { provider: row.provider, model: row.model }
464
611
  return `${row.provider}/${row.model}`
465
612
  }
@@ -470,6 +617,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
470
617
  * workspace; an absolute or cwd-relative argument overrides it.
471
618
  */
472
619
  const exportTranscript = async (argument: string): Promise<void> => {
620
+ if (session === undefined) {
621
+ bridge.notify('no session yet — submit a message to start', 'warning')
622
+ return
623
+ }
473
624
  const wanted = argument.trim()
474
625
  const sessionCwd = session.header.cwd ?? cwd
475
626
  const defaultName = `dsh-session-${session.id.slice(-8)}.md`
@@ -495,6 +646,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
495
646
  const renameTitle = (argument: string): string => {
496
647
  const title = argument.trim()
497
648
  if (title === '') return 'usage: /title <text>'
649
+ if (session === undefined) return 'no session yet — submit a message to start'
498
650
  const service = ctx.get('sessionTitle')
499
651
  if (service === undefined) return 'session titles are unavailable in this profile'
500
652
  try {
@@ -524,10 +676,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
524
676
 
525
677
  const switchModeAction = async (id: string): Promise<string> => {
526
678
  if (id === '') throw new Error('usage: /mode <preset>')
527
- const preset = await switchPreset(presets, agent, id)
528
- active.mode = preset.id
529
- commands.setAgent(agent)
530
- skills.setAgent(agent)
679
+ const currentAgent = agent
680
+ const currentActive = active
681
+ if (currentAgent === undefined || currentActive === undefined) {
682
+ throw new Error('no session yet — submit a message to start')
683
+ }
684
+ const preset = await switchPreset(presets, currentAgent, id)
685
+ currentActive.mode = preset.id
686
+ commands.setAgent(currentAgent)
687
+ skills.setAgent(currentAgent)
531
688
  renderCurrent()
532
689
  return preset.id
533
690
  }
@@ -549,16 +706,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
549
706
  renderCurrent()
550
707
  } catch (error: unknown) {
551
708
  active = previous
552
- agent = previous.agent
553
- session = previous.session
554
- store = previous.store
555
- mentions = previous.mentions
556
- commands.setAgent(agent)
557
- skills.setAgent(agent)
709
+ agent = previous?.agent
710
+ session = previous?.session
711
+ store = previous === undefined ? createTranscriptStore() : previous.store
712
+ mentions = previous?.mentions
713
+ if (agent !== undefined) commands.setAgent(agent)
714
+ if (agent !== undefined) skills.setAgent(agent)
558
715
  await next.handle.dispose()
559
716
  renderCurrent()
560
717
  throw error
561
718
  }
719
+ // No previous session (a bare launch switched straight into a resume):
720
+ // nothing to flush or dispose, so just confirm the activation.
721
+ if (previous === undefined) {
722
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
723
+ return
724
+ }
562
725
  let cleanupWarning: string | undefined
563
726
  try {
564
727
  await sessions.flush(previous.session)
@@ -582,11 +745,20 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
582
745
  )
583
746
 
584
747
  const requestSwitch = (request: PendingSwitch): void => {
748
+ if (session === undefined) {
749
+ // No session yet (a bare launch using /resume before any input): activate
750
+ // the target directly — there is no running turn to wait on and nothing
751
+ // to flush.
752
+ void activate(request.target).catch((error: unknown) => {
753
+ bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
754
+ })
755
+ return
756
+ }
585
757
  if (request.target.sessionId === session.id) {
586
758
  bridge.notify('that session is already active', 'warning')
587
759
  return
588
760
  }
589
- const outcome = switchQueue.request(agent, request)
761
+ const outcome = switchQueue.request(agent!, request)
590
762
  if (outcome === 'queued') {
591
763
  bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
592
764
  }
@@ -603,7 +775,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
603
775
  if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
604
776
  throw new Error('subagent conversations are read-only in /resume; resume a root session')
605
777
  }
606
- if (agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
778
+ if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
607
779
  throw new Error('that session is already live in another owner')
608
780
  }
609
781
  return matches[0]!.header.id
@@ -616,6 +788,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
616
788
  }
617
789
 
618
790
  const createSession = (mode?: string): void => {
791
+ // /new before any input is the first-session creation itself, not a switch.
792
+ if (session === undefined) {
793
+ ensureSession(mode)
794
+ return
795
+ }
619
796
  const nextCwd = session.header.cwd ?? cwd
620
797
  const id = `session-${randomUUID()}`
621
798
  requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
@@ -634,10 +811,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
634
811
  }
635
812
 
636
813
  const appElement = (): ReturnType<typeof createElement> => {
637
- const sessionCwd = session.header.cwd ?? cwd
814
+ // A bare launch mounts with placeholder facts until the first input
815
+ // composes a real session: empty session id/mode, the deployment default
816
+ // model, and the working directory's basename. `status.ts` drops empty
817
+ // mode/sessionId, so the bar renders only the identity it actually has.
818
+ const sessionCwd = session?.header.cwd ?? cwd
638
819
  const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
639
820
  return createElement(App, {
640
- key: session.id,
821
+ key: session?.id ?? 'pending',
641
822
  store,
642
823
  approval,
643
824
  questions,
@@ -647,15 +828,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
647
828
  cwd: basename(sessionCwd),
648
829
  workspaceRoot: sessionCwd,
649
830
  branch: gitBranch(sessionCwd),
650
- sessionId: session.id.slice(-8),
651
- resumed: active.resumed,
652
- mode: active.mode,
831
+ sessionId: session === undefined ? '' : session.id.slice(-8),
832
+ resumed: active?.resumed ?? false,
833
+ mode: active?.mode ?? '',
653
834
  dispatch,
654
835
  steer,
655
836
  interrupt,
656
837
  quit,
657
838
  loadModels: () => loadModelDirectory(ctx),
658
- loadMentions: mentions.candidates,
839
+ loadMentions: mentions === undefined
840
+ ? () => Promise.resolve<readonly MentionCandidate[]>([])
841
+ : mentions.candidates,
659
842
  cyclePermission,
660
843
  selectModel,
661
844
  exportTranscript,
@@ -668,6 +851,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
668
851
  switchSession,
669
852
  cancelSessionSwitch,
670
853
  loadPlugins: () => listPluginRows(ctx),
854
+ statusline: statuslineItems,
855
+ saveStatusline,
856
+ history: inputHistory,
857
+ recordHistory,
858
+ cancelQueued,
671
859
  onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
672
860
  })
673
861
  }
@@ -677,6 +865,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
677
865
  }
678
866
 
679
867
  mountRef.current = io.mount(appElement())
868
+
869
+ // A corrupt statusline config must not vanish silently: surface it once
870
+ // the notice channel is live, after the first frame settles.
871
+ if (statuslineWarning !== undefined) {
872
+ setTimeout(() => {
873
+ bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
874
+ }, 50)
875
+ }
680
876
  }
681
877
 
682
878
  /**